From b68043ae8c59b57a038117fcafcbb23154faa23f Mon Sep 17 00:00:00 2001 From: Lorenzo Affetti Date: Thu, 14 Feb 2019 17:38:18 +0100 Subject: [PATCH 01/54] fix(query/stdlib/influxdb): make FromOpSpec a BucketAwareSpec --- query/preauthorizer_test.go | 49 ++++++++++++------- query/stdlib/influxdata/influxdb/from.go | 25 ++++++++-- query/stdlib/influxdata/influxdb/from_test.go | 21 +++++--- query/stdlib/influxdata/influxdb/to.go | 8 ++- query/stdlib/influxdata/influxdb/to_test.go | 16 +++--- 5 files changed, 83 insertions(+), 36 deletions(-) diff --git a/query/preauthorizer_test.go b/query/preauthorizer_test.go index 55364701a2..fc52db30c0 100644 --- a/query/preauthorizer_test.go +++ b/query/preauthorizer_test.go @@ -28,45 +28,56 @@ func newBucketServiceWithOneBucket(bucket platform.Bucket) platform.BucketServic } func TestPreAuthorizer_PreAuthorize(t *testing.T) { - // TODO(adam) add this test back when BucketsAccessed is restored for the from function - // https://github.com/influxdata/flux/issues/114 - t.Skip("https://github.com/influxdata/flux/issues/114") ctx := context.Background() now := time.Now().UTC() - - q := `from(bucket:"my_bucket") |> range(start:-2h) |> yield()` - spec, err := flux.Compile(ctx, q, now) - if err != nil { - t.Fatalf("Error compiling query: %v", err) - } - - // Try to pre-authorize with bucket service with no buckets - // and no authorization + // fresh pre-authorizer auth := &platform.Authorization{Status: platform.Active} emptyBucketService := mock.NewBucketService() preAuthorizer := query.NewPreAuthorizer(emptyBucketService) + // Try to pre-authorize invalid bucketID + q := `from(bucketID:"invalid") |> range(start:-2h) |> yield()` + spec, err := flux.Compile(ctx, q, now) + if err != nil { + t.Fatalf("Error compiling query: %v", err) + } err = preAuthorizer.PreAuthorize(ctx, spec, auth) - if diagnostic := cmp.Diff("Bucket service returned nil bucket", err.Error()); diagnostic != "" { + if diagnostic := cmp.Diff("bucket service returned nil bucket", err.Error()); diagnostic != "" { + t.Errorf("Authorize message mismatch: -want/+got:\n%v", diagnostic) + } + + // Try to pre-authorize a valid from with bucket service with no buckets + // and no authorization + q = `from(bucket:"my_bucket") |> range(start:-2h) |> yield()` + spec, err = flux.Compile(ctx, q, now) + if err != nil { + t.Fatalf("Error compiling query: %v", err) + } + err = preAuthorizer.PreAuthorize(ctx, spec, auth) + if diagnostic := cmp.Diff("bucket service returned nil bucket", err.Error()); diagnostic != "" { t.Errorf("Authorize message mismatch: -want/+got:\n%v", diagnostic) } // Try to authorize with a bucket service that knows about one bucket // (still no authorization) - id, _ := platform.IDFromString("deadbeefdeadbeef") + bucketID, err := platform.IDFromString("deadbeefdeadbeef") + if err != nil { + t.Fatal(err) + } + orgID := platform.ID(1) bucketService := newBucketServiceWithOneBucket(platform.Bucket{ - Name: "my_bucket", - ID: *id, + Name: "my_bucket", + ID: *bucketID, + OrganizationID: orgID, }) preAuthorizer = query.NewPreAuthorizer(bucketService) err = preAuthorizer.PreAuthorize(ctx, spec, auth) - if diagnostic := cmp.Diff(`No read permission for bucket: "my_bucket"`, err.Error()); diagnostic != "" { + if diagnostic := cmp.Diff(`no read permission for bucket: "my_bucket"`, err.Error()); diagnostic != "" { t.Errorf("Authorize message mismatch: -want/+got:\n%v", diagnostic) } - orgID := platform.ID(1) - p, err := platform.NewPermissionAtID(*id, platform.ReadAction, platform.BucketsResourceType, orgID) + p, err := platform.NewPermissionAtID(*bucketID, platform.ReadAction, platform.BucketsResourceType, orgID) if err != nil { t.Fatalf("Error creating read bucket permission query: %v", err) } diff --git a/query/stdlib/influxdata/influxdb/from.go b/query/stdlib/influxdata/influxdb/from.go index cbe9e8092b..ac273ed88b 100644 --- a/query/stdlib/influxdata/influxdb/from.go +++ b/query/stdlib/influxdata/influxdb/from.go @@ -79,6 +79,28 @@ func (s *FromOpSpec) Kind() flux.OperationKind { return FromKind } +// BucketsAccessed makes FromOpSpec a query.BucketAwareOperationSpec +func (s *FromOpSpec) BucketsAccessed() (readBuckets, writeBuckets []platform.BucketFilter) { + bf := platform.BucketFilter{} + if s.Bucket != "" { + bf.Name = &s.Bucket + } + + if len(s.BucketID) > 0 { + if id, err := platform.IDFromString(s.BucketID); err != nil { + invalidID := platform.InvalidID() + bf.ID = &invalidID + } else { + bf.ID = id + } + } + + if bf.ID != nil || bf.Name != nil { + readBuckets = append(readBuckets, bf) + } + return readBuckets, writeBuckets +} + type FromProcedureSpec struct { Bucket string BucketID string @@ -623,9 +645,6 @@ func (FromKeysRule) Rewrite(keysNode plan.PlanNode) (plan.PlanNode, bool, error) return keysNode, true, nil } -// TODO(adam): implement a BucketsAccessed that doesn't depend on flux. -// https://github.com/influxdata/flux/issues/114 - func createFromSource(prSpec plan.ProcedureSpec, dsid execute.DatasetID, a execute.Administration) (execute.Source, error) { spec := prSpec.(*PhysicalFromProcedureSpec) var w execute.Window diff --git a/query/stdlib/influxdata/influxdb/from_test.go b/query/stdlib/influxdata/influxdb/from_test.go index 7be4f3f393..90e8e367b5 100644 --- a/query/stdlib/influxdata/influxdb/from_test.go +++ b/query/stdlib/influxdata/influxdb/from_test.go @@ -1,6 +1,7 @@ package influxdb_test import ( + "fmt" "testing" "time" @@ -122,24 +123,32 @@ func TestFromOperation_Marshaling(t *testing.T) { } func TestFromOpSpec_BucketsAccessed(t *testing.T) { - // TODO(adam) add this test back when BucketsAccessed is restored for the from function - // https://github.com/influxdata/flux/issues/114 - t.Skip("https://github.com/influxdata/flux/issues/114") bucketName := "my_bucket" - bucketID, _ := platform.IDFromString("deadbeef") + bucketIDString := "aaaabbbbccccdddd" + bucketID, err := platform.IDFromString(bucketIDString) + if err != nil { + t.Fatal(err) + } + invalidID := platform.InvalidID() tests := []pquerytest.BucketAwareQueryTestCase{ { Name: "From with bucket", - Raw: `from(bucket:"my_bucket")`, + Raw: fmt.Sprintf(`from(bucket:"%s")`, bucketName), WantReadBuckets: &[]platform.BucketFilter{{Name: &bucketName}}, WantWriteBuckets: &[]platform.BucketFilter{}, }, { Name: "From with bucketID", - Raw: `from(bucketID:"deadbeef")`, + Raw: fmt.Sprintf(`from(bucketID:"%s")`, bucketID), WantReadBuckets: &[]platform.BucketFilter{{ID: bucketID}}, WantWriteBuckets: &[]platform.BucketFilter{}, }, + { + Name: "From invalid bucketID", + Raw: `from(bucketID:"invalid")`, + WantReadBuckets: &[]platform.BucketFilter{{ID: &invalidID}}, + WantWriteBuckets: &[]platform.BucketFilter{}, + }, } for _, tc := range tests { tc := tc diff --git a/query/stdlib/influxdata/influxdb/to.go b/query/stdlib/influxdata/influxdb/to.go index ffdecf6766..d8f04f90c1 100644 --- a/query/stdlib/influxdata/influxdb/to.go +++ b/query/stdlib/influxdata/influxdb/to.go @@ -172,7 +172,13 @@ func (ToOpSpec) Kind() flux.OperationKind { // BucketsAccessed returns the buckets accessed by the spec. func (o *ToOpSpec) BucketsAccessed() (readBuckets, writeBuckets []platform.BucketFilter) { - bf := platform.BucketFilter{Name: &o.Bucket, Organization: &o.Org} + bf := platform.BucketFilter{} + if o.Bucket != "" { + bf.Name = &o.Bucket + } + if o.Org != "" { + bf.Organization = &o.Org + } if o.OrgID != "" { id, err := platform.IDFromString(o.OrgID) if err == nil { diff --git a/query/stdlib/influxdata/influxdb/to_test.go b/query/stdlib/influxdata/influxdb/to_test.go index 641634cdf9..db5718d971 100644 --- a/query/stdlib/influxdata/influxdb/to_test.go +++ b/query/stdlib/influxdata/influxdb/to_test.go @@ -2,6 +2,7 @@ package influxdb_test import ( "context" + "fmt" "testing" "github.com/google/go-cmp/cmp" @@ -82,24 +83,25 @@ func TestTo_Query(t *testing.T) { } func TestToOpSpec_BucketsAccessed(t *testing.T) { - // TODO(adam) add this test back when BucketsAccessed is restored for the from function - // https://github.com/influxdata/flux/issues/114 - t.Skip("https://github.com/influxdata/flux/issues/114") bucketName := "my_bucket" orgName := "my_org" - id := platform.ID(1) + orgIDString := "aaaabbbbccccdddd" + orgID, err := platform.IDFromString(orgIDString) + if err != nil { + t.Fatal(err) + } tests := []querytest.BucketAwareQueryTestCase{ { Name: "from() with bucket and to with org and bucket", - Raw: `from(bucket:"my_bucket") |> to(bucket:"my_bucket", org:"my_org")`, + Raw: fmt.Sprintf(`from(bucket:"%s") |> to(bucket:"%s", org:"%s")`, bucketName, bucketName, orgName), WantReadBuckets: &[]platform.BucketFilter{{Name: &bucketName}}, WantWriteBuckets: &[]platform.BucketFilter{{Name: &bucketName, Organization: &orgName}}, }, { Name: "from() with bucket and to with orgID and bucket", - Raw: `from(bucket:"my_bucket") |> to(bucket:"my_bucket", orgID:"0000000000000001")`, + Raw: fmt.Sprintf(`from(bucket:"%s") |> to(bucket:"%s", orgID:"%s")`, bucketName, bucketName, orgIDString), WantReadBuckets: &[]platform.BucketFilter{{Name: &bucketName}}, - WantWriteBuckets: &[]platform.BucketFilter{{Name: &bucketName, OrganizationID: &id}}, + WantWriteBuckets: &[]platform.BucketFilter{{Name: &bucketName, OrganizationID: orgID}}, }, } From 561902e5c498dbb18881468b1331d7bc1377c02b Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Thu, 21 Feb 2019 14:02:35 -0800 Subject: [PATCH 02/54] Update the swagger definition for a telegraf labels post response --- http/swagger.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/http/swagger.yml b/http/swagger.yml index 96e3515f77..5113196721 100644 --- a/http/swagger.yml +++ b/http/swagger.yml @@ -336,11 +336,11 @@ paths: $ref: "#/components/schemas/LabelMapping" responses: '200': - description: a list of all labels for a telegraf config + description: "the label added to the telegraf config" content: application/json: schema: - $ref: "#/components/schemas/LabelsResponse" + $ref: "#/components/schemas/LabelResponse" default: description: unexpected error content: @@ -2136,7 +2136,7 @@ paths: $ref: "#/components/schemas/LabelMapping" responses: '200': - description: a list of all labels for a dashboard + description: the label added to the dashboard content: application/json: schema: From 86147c280844217dad07a8803dcded111168a1de Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Thu, 21 Feb 2019 14:52:14 -0800 Subject: [PATCH 03/54] update to use org name to create task --- ui/src/tasks/actions/v2/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/tasks/actions/v2/index.ts b/ui/src/tasks/actions/v2/index.ts index 9ee119db4f..8a5b4e1965 100644 --- a/ui/src/tasks/actions/v2/index.ts +++ b/ui/src/tasks/actions/v2/index.ts @@ -384,7 +384,7 @@ export const saveNewScript = ( org = orgs[0] } - await client.tasks.create(getDeep(org, 'id', ''), scriptWithOptions) + await client.tasks.create(org.name, scriptWithOptions) dispatch(setNewScript('')) dispatch(clearTask()) From c664e8e0d80044b4d7da8256a12ad165fa1676a6 Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Thu, 21 Feb 2019 14:50:55 -0800 Subject: [PATCH 04/54] Fix query bug resulting from missing org ID The `/api/v2/query` endpoint requires an organization or organizationID query parameter. Previously, there existed a bug in the API where if the organization parameters were left off, the API would use the first organization created in the backend, rather than returning a 400 error. We built the entire UI on top of this bug, but it has now been fixed. So in every location where we use the `/api/v2/query` endpoint, we need to supply an organization. This commit updates all such locations to use the first organization present in our Redux store as the organization parameter, thus roughly reproducing the behavior of the load bearing bug. This is just a quick fix. Long term, we will want to think about what organization queries should run under and build an appropriate UI around that design. --- .../verifyStep/DataListening.test.tsx | 47 +++++++++---------- .../components/verifyStep/DataListening.tsx | 23 +++++++-- ui/src/organizations/selectors/index.ts | 3 ++ ui/src/shared/apis/v2/query.ts | 3 +- ui/src/shared/components/TimeSeries.tsx | 18 ++++--- ui/src/timeMachine/actions/queryBuilder.ts | 11 ++++- .../timeMachine/apis/QueryBuilderFetcher.ts | 15 ++++-- ui/src/timeMachine/apis/queryBuilder.ts | 10 ++-- .../components/QueryBuilder.test.tsx | 5 ++ 9 files changed, 90 insertions(+), 45 deletions(-) create mode 100644 ui/src/organizations/selectors/index.ts diff --git a/ui/src/dataLoaders/components/verifyStep/DataListening.test.tsx b/ui/src/dataLoaders/components/verifyStep/DataListening.test.tsx index c30e3b77c2..8d261e3f0e 100644 --- a/ui/src/dataLoaders/components/verifyStep/DataListening.test.tsx +++ b/ui/src/dataLoaders/components/verifyStep/DataListening.test.tsx @@ -1,44 +1,39 @@ // Libraries import React from 'react' -import {shallow} from 'enzyme' // Components import DataListening from 'src/dataLoaders/components/verifyStep/DataListening' -import ConnectionInformation from 'src/dataLoaders/components/verifyStep/ConnectionInformation' -import {Button} from '@influxdata/clockface' -const setup = (override = {}) => { - const props = { - bucket: 'defbuck', - stepIndex: 4, - ...override, +// Utils +import {renderWithRedux} from 'src/mockState' +import {fireEvent} from 'react-testing-library' + +const setInitialState = state => { + return { + ...state, + orgs: [ + { + id: 'foo', + }, + ], } - - const wrapper = shallow() - - return {wrapper} } describe('Onboarding.Components.DataListening', () => { - it('renders', () => { - const {wrapper} = setup() - const button = wrapper.find(Button) - - expect(wrapper.exists()).toBe(true) - expect(button.exists()).toBe(true) - }) - describe('if button is clicked', () => { it('displays connection information', () => { - const {wrapper} = setup() + const {getByTitle, getByText} = renderWithRedux( + , + setInitialState + ) - const button = wrapper.find(Button) - button.simulate('click') + const button = getByTitle('Listen for Data') - const connectionInfo = wrapper.find(ConnectionInformation) + fireEvent.click(button) - expect(wrapper.exists()).toBe(true) - expect(connectionInfo.exists()).toBe(true) + const message = getByText('Awaiting Connection...') + + expect(message).toBeDefined() }) }) }) diff --git a/ui/src/dataLoaders/components/verifyStep/DataListening.tsx b/ui/src/dataLoaders/components/verifyStep/DataListening.tsx index b03931aa36..2e7b464e7f 100644 --- a/ui/src/dataLoaders/components/verifyStep/DataListening.tsx +++ b/ui/src/dataLoaders/components/verifyStep/DataListening.tsx @@ -1,9 +1,11 @@ // Libraries import React, {PureComponent} from 'react' +import {connect} from 'react-redux' import _ from 'lodash' // Apis import {executeQuery} from 'src/shared/apis/v2/query' +import {getActiveOrg} from 'src/organizations/selectors' // Components import {ErrorHandling} from 'src/shared/decorators/errors' @@ -18,12 +20,19 @@ import ConnectionInformation, { } from 'src/dataLoaders/components/verifyStep/ConnectionInformation' // Types +import {AppState, Organization} from 'src/types/v2' import {InfluxLanguage} from 'src/types/v2/dashboards' -export interface Props { +interface OwnProps { bucket: string } +interface StateProps { + activeOrg: Organization +} + +type Props = OwnProps & StateProps + interface State { loading: LoadingState timePassedInSeconds: number @@ -112,7 +121,7 @@ class DataListening extends PureComponent { } private checkForData = async (): Promise => { - const {bucket} = this.props + const {bucket, activeOrg} = this.props const {secondsLeft} = this.state const script = `from(bucket: "${bucket}") |> range(start: -1m)` @@ -123,6 +132,7 @@ class DataListening extends PureComponent { try { const response = await executeQuery( '/api/v2/query', + activeOrg.id, script, InfluxLanguage.Flux ).promise @@ -165,4 +175,11 @@ class DataListening extends PureComponent { } } -export default DataListening +const mstp = (state: AppState) => ({ + activeOrg: getActiveOrg(state), +}) + +export default connect( + mstp, + null +)(DataListening) diff --git a/ui/src/organizations/selectors/index.ts b/ui/src/organizations/selectors/index.ts new file mode 100644 index 0000000000..e42fae6661 --- /dev/null +++ b/ui/src/organizations/selectors/index.ts @@ -0,0 +1,3 @@ +import {AppState, Organization} from 'src/types/v2' + +export const getActiveOrg = (state: AppState): Organization => state.orgs[0] diff --git a/ui/src/shared/apis/v2/query.ts b/ui/src/shared/apis/v2/query.ts index 71ad7e1f32..0726684dea 100644 --- a/ui/src/shared/apis/v2/query.ts +++ b/ui/src/shared/apis/v2/query.ts @@ -20,6 +20,7 @@ interface XHRError extends Error { export const executeQuery = ( url: string, + orgID: string, query: string, language: InfluxLanguage = InfluxLanguage.Flux ): WrappedCancelablePromise => { @@ -127,7 +128,7 @@ export const executeQuery = ( const dialect = {annotations: ['group', 'datatype', 'default']} const body = JSON.stringify({query, dialect, type: language}) - xhr.open('POST', url) + xhr.open('POST', `${url}?orgID=${encodeURIComponent(orgID)}`) xhr.setRequestHeader('Content-Type', 'application/json') xhr.send(body) diff --git a/ui/src/shared/components/TimeSeries.tsx b/ui/src/shared/components/TimeSeries.tsx index c764836d6c..c24d36943d 100644 --- a/ui/src/shared/components/TimeSeries.tsx +++ b/ui/src/shared/components/TimeSeries.tsx @@ -10,18 +10,20 @@ import {executeQuery, ExecuteFluxQueryResult} from 'src/shared/apis/v2/query' import {parseResponse} from 'src/shared/parsing/flux/response' import {getSources, getActiveSource} from 'src/sources/selectors' import {renderQuery} from 'src/shared/utils/renderQuery' +import {getActiveOrg} from 'src/organizations/selectors' // Types import {RemoteDataState, FluxTable} from 'src/types' import {DashboardQuery} from 'src/types/v2/dashboards' -import {AppState, Source} from 'src/types/v2' +import {AppState, Source, Organization} from 'src/types/v2' import {WrappedCancelablePromise, CancellationError} from 'src/types/promises' type URLQuery = DashboardQuery & {url: string} const executeRenderedQuery = ( {text, type, url}: URLQuery, - variables: {[key: string]: string} + variables: {[key: string]: string}, + orgID: string ): WrappedCancelablePromise => { let isCancelled = false let cancelExecution @@ -39,7 +41,7 @@ const executeRenderedQuery = ( return Promise.reject(new CancellationError()) } - const pendingResult = executeQuery(url, renderedQuery, type) + const pendingResult = executeQuery(url, orgID, renderedQuery, type) cancelExecution = pendingResult.cancel @@ -61,6 +63,7 @@ export interface QueriesState { interface StateProps { dynamicSourceURL: string sources: Source[] + activeOrg: Organization } interface OwnProps { @@ -141,7 +144,7 @@ class TimeSeries extends Component { } private reload = async () => { - const {inView, variables} = this.props + const {inView, variables, activeOrg} = this.props const queries = this.queries if (!inView) { @@ -167,7 +170,9 @@ class TimeSeries extends Component { this.pendingResults.forEach(({cancel}) => cancel()) // Issue new queries - this.pendingResults = queries.map(q => executeRenderedQuery(q, variables)) + this.pendingResults = queries.map(q => + executeRenderedQuery(q, variables, activeOrg.id) + ) // Wait for new queries to complete const results = await Promise.all(this.pendingResults.map(r => r.promise)) @@ -218,8 +223,9 @@ class TimeSeries extends Component { const mstp = (state: AppState) => { const sources = getSources(state) const dynamicSourceURL = getActiveSource(state).links.query + const activeOrg = getActiveOrg(state) - return {sources, dynamicSourceURL} + return {sources, dynamicSourceURL, activeOrg} } export default connect( diff --git a/ui/src/timeMachine/actions/queryBuilder.ts b/ui/src/timeMachine/actions/queryBuilder.ts index 92432a35e6..85b0b587fc 100644 --- a/ui/src/timeMachine/actions/queryBuilder.ts +++ b/ui/src/timeMachine/actions/queryBuilder.ts @@ -2,6 +2,7 @@ import {queryBuilderFetcher} from 'src/timeMachine/apis/QueryBuilderFetcher' // Utils +import {getActiveOrg} from 'src/organizations/selectors' import { getActiveQuerySource, getActiveQuery, @@ -203,11 +204,13 @@ export const loadBuckets = () => async ( dispatch: Dispatch, getState: GetState ) => { + const queryURL = getActiveQuerySource(getState()).links.query + const orgID = getActiveOrg(getState()).id + dispatch(setBuilderBucketsStatus(RemoteDataState.Loading)) try { - const queryURL = getActiveQuerySource(getState()).links.query - const buckets = await queryBuilderFetcher.findBuckets(queryURL) + const buckets = await queryBuilderFetcher.findBuckets(queryURL, orgID) const selectedBucket = getActiveQuery(getState()).builderConfig.buckets[0] dispatch(setBuilderBuckets(buckets)) @@ -247,6 +250,7 @@ export const loadTagSelector = (index: number) => async ( const tagPredicates = tags.slice(0, index) const queryURL = getActiveQuerySource(getState()).links.query + const orgID = getActiveOrg(getState()).id dispatch(setBuilderTagKeysStatus(index, RemoteDataState.Loading)) @@ -257,6 +261,7 @@ export const loadTagSelector = (index: number) => async ( const keys = await queryBuilderFetcher.findKeys( index, queryURL, + orgID, buckets[0], tagPredicates, searchTerm @@ -299,6 +304,7 @@ const loadTagSelectorValues = (index: number) => async ( const {buckets, tags} = getActiveQuery(getState()).builderConfig const tagPredicates = tags.slice(0, index) const queryURL = getActiveQuerySource(getState()).links.query + const orgID = getActiveOrg(getState()).id dispatch(setBuilderTagValuesStatus(index, RemoteDataState.Loading)) @@ -309,6 +315,7 @@ const loadTagSelectorValues = (index: number) => async ( const values = await queryBuilderFetcher.findValues( index, queryURL, + orgID, buckets[0], tagPredicates, key, diff --git a/ui/src/timeMachine/apis/QueryBuilderFetcher.ts b/ui/src/timeMachine/apis/QueryBuilderFetcher.ts index 5adb61b89f..3f5c249555 100644 --- a/ui/src/timeMachine/apis/QueryBuilderFetcher.ts +++ b/ui/src/timeMachine/apis/QueryBuilderFetcher.ts @@ -19,7 +19,7 @@ class QueryBuilderFetcher { private findValuesCache: {[key: string]: string[]} = {} private findBucketsCache: {[key: string]: string[]} = {} - public async findBuckets(url: string): Promise { + public async findBuckets(url: string, orgID: string): Promise { this.cancelFindBuckets() const cacheKey = JSON.stringify([...arguments]) @@ -29,7 +29,7 @@ class QueryBuilderFetcher { return Promise.resolve(cachedResult) } - const pendingResult = findBuckets(url) + const pendingResult = findBuckets(url, orgID) pendingResult.promise.then(result => { this.findBucketsCache[cacheKey] = result @@ -47,6 +47,7 @@ class QueryBuilderFetcher { public async findKeys( index: number, url: string, + orgID: string, bucket: string, tagsSelections: BuilderConfig['tags'], searchTerm: string = '' @@ -60,7 +61,13 @@ class QueryBuilderFetcher { return Promise.resolve(cachedResult) } - const pendingResult = findKeys(url, bucket, tagsSelections, searchTerm) + const pendingResult = findKeys( + url, + orgID, + bucket, + tagsSelections, + searchTerm + ) this.findKeysQueries[index] = pendingResult @@ -80,6 +87,7 @@ class QueryBuilderFetcher { public async findValues( index: number, url: string, + orgID: string, bucket: string, tagsSelections: BuilderConfig['tags'], key: string, @@ -96,6 +104,7 @@ class QueryBuilderFetcher { const pendingResult = findValues( url, + orgID, bucket, tagsSelections, key, diff --git a/ui/src/timeMachine/apis/queryBuilder.ts b/ui/src/timeMachine/apis/queryBuilder.ts index 25be4c3576..573e9bcf85 100644 --- a/ui/src/timeMachine/apis/queryBuilder.ts +++ b/ui/src/timeMachine/apis/queryBuilder.ts @@ -14,12 +14,12 @@ export const LIMIT = 200 type CancelableQuery = WrappedCancelablePromise -export function findBuckets(url: string): CancelableQuery { +export function findBuckets(url: string, orgID: string): CancelableQuery { const query = `buckets() |> sort(columns: ["name"]) |> limit(n: ${LIMIT})` - const {promise, cancel} = executeQuery(url, query, InfluxLanguage.Flux) + const {promise, cancel} = executeQuery(url, orgID, query, InfluxLanguage.Flux) return { promise: promise.then(resp => extractCol(resp, 'name')), @@ -29,6 +29,7 @@ export function findBuckets(url: string): CancelableQuery { export function findKeys( url: string, + orgID: string, bucket: string, tagsSelections: BuilderConfig['tags'], searchTerm: string = '' @@ -49,7 +50,7 @@ v1.tagKeys(bucket: "${bucket}", predicate: ${tagFilters}, start: -${SEARCH_DURAT |> sort() |> limit(n: ${LIMIT})` - const {promise, cancel} = executeQuery(url, query, InfluxLanguage.Flux) + const {promise, cancel} = executeQuery(url, orgID, query, InfluxLanguage.Flux) return { promise: promise.then(resp => extractCol(resp, '_value')), @@ -59,6 +60,7 @@ v1.tagKeys(bucket: "${bucket}", predicate: ${tagFilters}, start: -${SEARCH_DURAT export function findValues( url: string, + orgID: string, bucket: string, tagsSelections: BuilderConfig['tags'], key: string, @@ -73,7 +75,7 @@ v1.tagValues(bucket: "${bucket}", tag: "${key}", predicate: ${tagFilters}, start |> limit(n: ${LIMIT}) |> sort()` - const {promise, cancel} = executeQuery(url, query, InfluxLanguage.Flux) + const {promise, cancel} = executeQuery(url, orgID, query, InfluxLanguage.Flux) return { promise: promise.then(resp => extractCol(resp, '_value')), diff --git a/ui/src/timeMachine/components/QueryBuilder.test.tsx b/ui/src/timeMachine/components/QueryBuilder.test.tsx index 88aef58d1f..35b2315d94 100644 --- a/ui/src/timeMachine/components/QueryBuilder.test.tsx +++ b/ui/src/timeMachine/components/QueryBuilder.test.tsx @@ -17,6 +17,11 @@ const setInitialState = state => { [source.id]: source, }, }, + orgs: [ + { + id: 'foo', + }, + ], } } From 80ebf933aada1b79e39408cbcbdb99226283bb3c Mon Sep 17 00:00:00 2001 From: alexpaxton Date: Thu, 21 Feb 2019 15:29:06 -0800 Subject: [PATCH 05/54] Polish Inline Label Creation (#12070) * Condense appearance of inline create label form Co-Authored-By: Delmer * Fine tune copy and click outside behavior Co-Authored-By: Delmer * Show label color error color when invalid hex Co-Authored-By: Delmer * Shrink width of random label color button Co-Authored-By: Delmer * Add full color palette to list of random label colors Co-Authored-By: Delmer * fix(ui/prettier-errors): fix ui prettier errors Co-authored-by: Alex Paxton --- .../components/label/LabelSelector.scss | 18 ++- .../components/label/LabelSelector.tsx | 19 +-- .../components/label/LabelSelectorMenu.tsx | 21 ++- .../label/LabelSelectorMenuItem.tsx | 2 +- .../components/RandomLabelColor.scss | 7 +- .../components/RandomLabelColor.tsx | 18 ++- ui/src/configuration/constants/LabelColors.ts | 144 ++++++++++++++++++ .../shared/components/ResourceLabelForm.scss | 13 +- .../shared/components/ResourceLabelForm.tsx | 87 +++++------ 9 files changed, 248 insertions(+), 81 deletions(-) diff --git a/ui/src/clockface/components/label/LabelSelector.scss b/ui/src/clockface/components/label/LabelSelector.scss index caf7118700..47181ad34b 100644 --- a/ui/src/clockface/components/label/LabelSelector.scss +++ b/ui/src/clockface/components/label/LabelSelector.scss @@ -26,30 +26,34 @@ } .label-selector--menu { + display: flex; + flex-wrap: wrap; width: 100%; - min-height: 50px; - padding: $ix-marg-b; + padding: $ix-marg-b - ($ix-border / 2); } .label-selector--menu-item { - margin: 1px; + display: inline-flex; + align-items: flex-start; + margin: $ix-border / 2; } .label-selector--empty { + width: 100%; font-size: 13px; font-weight: 500; user-select: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -.label-selector--empty { - padding: $ix-marg-a $ix-marg-b; color: $g9-mountain; font-style: italic; min-height: 30px; line-height: 30px; + + &:first-child { + margin-bottom: $ix-marg-b - ($ix-border / 2); + } } .label-selector--selection { diff --git a/ui/src/clockface/components/label/LabelSelector.tsx b/ui/src/clockface/components/label/LabelSelector.tsx index eb18452793..98f7b7dcd4 100644 --- a/ui/src/clockface/components/label/LabelSelector.tsx +++ b/ui/src/clockface/components/label/LabelSelector.tsx @@ -74,15 +74,15 @@ class LabelSelector extends Component { public render() { return ( - -
-
- {this.selectedLabels} - {this.clearSelectedButton} -
- {this.input} +
+
+ {this.selectedLabels} + {this.clearSelectedButton}
- + + {this.input} + +
) } @@ -169,8 +169,9 @@ class LabelSelector extends Component { private handleStartSuggesting = () => { const {availableLabels} = this + const {isSuggesting} = this.state - if (_.isEmpty(availableLabels)) { + if (_.isEmpty(availableLabels) && !isSuggesting) { return this.setState({ isSuggesting: true, highlightedID: null, diff --git a/ui/src/clockface/components/label/LabelSelectorMenu.tsx b/ui/src/clockface/components/label/LabelSelectorMenu.tsx index 79ec100c39..993569706d 100644 --- a/ui/src/clockface/components/label/LabelSelectorMenu.tsx +++ b/ui/src/clockface/components/label/LabelSelectorMenu.tsx @@ -30,8 +30,9 @@ class LabelSelectorMenu extends Component {
- {this.resourceLabelForm} {this.menuItems} + {this.emptyText} + {this.resourceLabelForm}
@@ -60,18 +61,22 @@ class LabelSelectorMenu extends Component { /> )) } - - return
{this.emptyText}
} - private get emptyText(): string { - const {allLabelsUsed} = this.props + private get emptyText(): JSX.Element { + const {allLabelsUsed, filterValue} = this.props - if (allLabelsUsed) { - return 'You have somehow managed to add all the labels, wow!' + if (!filterValue) { + return null } - return 'No labels match your query' + let text = `No labels match "${filterValue}" want to create a new label?` + + if (allLabelsUsed) { + text = 'You have somehow managed to add all the labels, wow!' + } + + return
{text}
} private get resourceLabelForm(): JSX.Element { diff --git a/ui/src/clockface/components/label/LabelSelectorMenuItem.tsx b/ui/src/clockface/components/label/LabelSelectorMenuItem.tsx index 5e6e6d7720..f645623e25 100644 --- a/ui/src/clockface/components/label/LabelSelectorMenuItem.tsx +++ b/ui/src/clockface/components/label/LabelSelectorMenuItem.tsx @@ -26,9 +26,9 @@ class LabelSelectorMenuItem extends Component {
) } @@ -157,6 +138,18 @@ export default class ResourceLabelForm extends PureComponent { }) } + private get createButtonLabel(): string { + const {labelName} = this.props + + let label = `Create "${labelName}"` + + if (labelName.length > MAX_CREATE_BUTTON_LENGTH) { + label = `Create "${labelName.slice(0, MAX_CREATE_BUTTON_LENGTH)}..."` + } + + return label + } + private get customColorInput(): JSX.Element { const {colorHex} = this From bf34b35fb4cf980db00e18278cc537f72c2d1b9e Mon Sep 17 00:00:00 2001 From: Delmer Date: Thu, 21 Feb 2019 18:39:56 -0500 Subject: [PATCH 06/54] fix(ui/changelog): add labels changes to changelog v2.0.0-alpha.4 (#12084) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4527abdc17..8c894079cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 1. [12009](https://github.com/influxdata/influxdb/pull/12009): Display the version information on the login page 1. [12011](https://github.com/influxdata/influxdb/pull/12011): Add the ability to update a Variable's name and query. 1. [12026](https://github.com/influxdata/influxdb/pull/12026): Add labels to cloned dashboard +1. [12018](https://github.com/influxdata/influxdb/pull/12057): Add ability filter resources by label name +1. [11973](https://github.com/influxdata/influxdb/pull/11973): Add ability to create or add labels to a resource from labels editor ### Bug Fixes 1. [11997](https://github.com/influxdata/influxdb/pull/11997): Update the bucket retention policy to update the time in seconds From ecb37d7cc40f613411e0409d829a3838a617ef8b Mon Sep 17 00:00:00 2001 From: Mark Rushakoff Date: Thu, 21 Feb 2019 16:16:28 -0800 Subject: [PATCH 07/54] fix(task): restore functionality for creating task with org name This is a partial rollback of changes #12004. Issue to track adding a test: #12089. --- http/task_service.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/http/task_service.go b/http/task_service.go index 9549c065b4..aa93d30169 100644 --- a/http/task_service.go +++ b/http/task_service.go @@ -435,6 +435,15 @@ func (h *TaskHandler) handlePostTask(w http.ResponseWriter, r *http.Request) { return } + if err := h.populateTaskCreateOrg(ctx, &req.TaskCreate); err != nil { + err = &platform.Error{ + Err: err, + Msg: "could not identify organization", + } + EncodeError(ctx, err, w) + return + } + if !req.TaskCreate.OrganizationID.Valid() { err := &platform.Error{ Code: platform.EInvalid, @@ -1126,6 +1135,31 @@ func decodeRetryRunRequest(ctx context.Context, r *http.Request) (*retryRunReque }, nil } +func (h *TaskHandler) populateTaskCreateOrg(ctx context.Context, tc *platform.TaskCreate) error { + if tc.OrganizationID.Valid() && tc.Organization != "" { + return nil + } + + if !tc.OrganizationID.Valid() && tc.Organization == "" { + return errors.New("missing orgID and organization name") + } + + if tc.OrganizationID.Valid() { + o, err := h.OrganizationService.FindOrganizationByID(ctx, tc.OrganizationID) + if err != nil { + return err + } + tc.Organization = o.Name + } else { + o, err := h.OrganizationService.FindOrganization(ctx, platform.OrganizationFilter{Name: &tc.Organization}) + if err != nil { + return err + } + tc.OrganizationID = o.ID + } + return nil +} + // TaskService connects to Influx via HTTP using tokens to manage tasks. type TaskService struct { Addr string From 8016599605faf3b85ae2f3ef98213f8586080dc8 Mon Sep 17 00:00:00 2001 From: Russ Savage Date: Thu, 21 Feb 2019 16:43:31 -0800 Subject: [PATCH 08/54] Updating changelog for alpha 4 --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c894079cf..ce9bfd7c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ -## v2.0.0-alpha.4 [unreleased] +## v2.0.0-alpha.5 [unreleased] + +### Features + +### Bug Fixes + +### UI Improvements + +## v2.0.0-alpha.4 [2019-02-21] ### Features 1. [11954](https://github.com/influxdata/influxdb/pull/11954): Add the ability to run a task manually from tasks page From df1a5e945023791467de3c9bfed838d4599a1e00 Mon Sep 17 00:00:00 2001 From: Andrew Watkins Date: Thu, 21 Feb 2019 11:14:12 -0800 Subject: [PATCH 09/54] test(e2e): use alisas instead of variables --- .../e2e/{dashboards.ts => dashboards.test.ts} | 22 +++++++++++++------ ui/cypress/support/commands.ts | 6 ++--- ui/cypress/tsconfig.json | 2 +- 3 files changed, 19 insertions(+), 11 deletions(-) rename ui/cypress/e2e/{dashboards.ts => dashboards.test.ts} (77%) diff --git a/ui/cypress/e2e/dashboards.ts b/ui/cypress/e2e/dashboards.test.ts similarity index 77% rename from ui/cypress/e2e/dashboards.ts rename to ui/cypress/e2e/dashboards.test.ts index 4141dc6fb2..dbf28b3d75 100644 --- a/ui/cypress/e2e/dashboards.ts +++ b/ui/cypress/e2e/dashboards.test.ts @@ -1,11 +1,15 @@ +import {Organization} from '@influxdata/influx' + describe('Dashboards', () => { - let orgID: string = '' beforeEach(() => { cy.flush() cy.setupUser().then(({body}) => { - orgID = body.org.id - cy.signin(orgID) + cy.wrap(body.org).as('org') + }) + + cy.get('@org').then(org => { + cy.signin(org.id) }) cy.fixture('routes').then(({dashboards}) => { @@ -40,8 +44,10 @@ describe('Dashboards', () => { }) it('can delete a dashboard', () => { - cy.createDashboard(orgID) - cy.createDashboard(orgID) + cy.get('@org').then(({id}) => { + cy.createDashboard(id) + cy.createDashboard(id) + }) cy.get('.index-list--row').then(rows => { const numDashboards = rows.length @@ -61,8 +67,10 @@ describe('Dashboards', () => { }) it('can edit a dashboards name', () => { - cy.createDashboard(orgID).then(({body}) => { - cy.visit(`/dashboards/${body.id}`) + cy.get('@org').then(({id}) => { + cy.createDashboard(id).then(({body}) => { + cy.visit(`/dashboards/${body.id}`) + }) }) const newName = 'new 🅱️ashboard' diff --git a/ui/cypress/support/commands.ts b/ui/cypress/support/commands.ts index 7875a7c14a..cee5f26f28 100644 --- a/ui/cypress/support/commands.ts +++ b/ui/cypress/support/commands.ts @@ -1,4 +1,4 @@ -export const signin = (orgID: string): Cypress.Chainable => { +export const signin = (orgID?: string): Cypress.Chainable => { return cy.fixture('user').then(user => { cy.request({ method: 'POST', @@ -12,7 +12,7 @@ export const signin = (orgID: string): Cypress.Chainable => { // createDashboard relies on an org fixture to be set export const createDashboard = ( - orgID: string + orgID?: string ): Cypress.Chainable => { return cy.request({ method: 'POST', @@ -45,7 +45,7 @@ export const createBucket = (): Cypress.Chainable => { } export const createSource = ( - orgID: string + orgID?: string ): Cypress.Chainable => { return cy.request({ method: 'POST', diff --git a/ui/cypress/tsconfig.json b/ui/cypress/tsconfig.json index c52feb9175..1d45b8329e 100644 --- a/ui/cypress/tsconfig.json +++ b/ui/cypress/tsconfig.json @@ -3,7 +3,7 @@ "strict": true, "baseUrl": "../node_modules", "target": "es5", - "lib": ["es5", "dom"], + "lib": ["es2017", "dom"], "types": ["cypress", "mocha", "node"] }, "include": ["**/*.ts"] From cb3f2e764451a8b672da0c899be90fe8d6eace4b Mon Sep 17 00:00:00 2001 From: Andrew Watkins Date: Thu, 21 Feb 2019 11:26:26 -0800 Subject: [PATCH 10/54] chore(jest): fix deprecations warning --- ui/package.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/package.json b/ui/package.json index 1ca5f9d38f..427b599088 100644 --- a/ui/package.json +++ b/ui/package.json @@ -35,12 +35,15 @@ "cypress:open": "cypress open" }, "jest": { - "setupTestFrameworkScriptFile": "./jestSetup.ts", + "setupFilesAfterEnv": [ + "./jestSetup.ts" + ], "displayName": "test", "testURL": "http://localhost", "testPathIgnorePatterns": [ "build", - "/node_modules/(?!(jest-test))" + "/node_modules/(?!(jest-test))", + "cypress" ], "setupFiles": [ "/testSetup.ts" From c76fd8ffbaff845d7cba419fd9fa6a2959afb186 Mon Sep 17 00:00:00 2001 From: Andrew Watkins Date: Thu, 21 Feb 2019 11:36:28 -0800 Subject: [PATCH 11/54] fix(jest): improperly mocked client call --- ui/src/dataLoaders/components/verifyStep/FetchAuthToken.tsx | 1 - ui/src/onboarding/apis/mocks.ts | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/src/dataLoaders/components/verifyStep/FetchAuthToken.tsx b/ui/src/dataLoaders/components/verifyStep/FetchAuthToken.tsx index 2d87645fda..5678e9ef0b 100644 --- a/ui/src/dataLoaders/components/verifyStep/FetchAuthToken.tsx +++ b/ui/src/dataLoaders/components/verifyStep/FetchAuthToken.tsx @@ -38,7 +38,6 @@ class FetchAuthToken extends PureComponent { const authToken = await client.authorizations.getAuthorizationToken( username ) - this.setState({authToken, loading: RemoteDataState.Done}) } diff --git a/ui/src/onboarding/apis/mocks.ts b/ui/src/onboarding/apis/mocks.ts index 756f8ec8f6..42ba30cfc2 100644 --- a/ui/src/onboarding/apis/mocks.ts +++ b/ui/src/onboarding/apis/mocks.ts @@ -22,12 +22,17 @@ export const telegrafsAPI = { telegrafsTelegrafIDPut, } +const getAuthorizationToken = jest.fn(() => Promise.resolve('im_an_auth_token')) + export const client = { telegrafConfigs: { getAll: telegrafsGet, getAllByOrg: telegrafsGet, create: telegrafsPost, }, + authorizations: { + getAuthorizationToken, + }, } export const setupAPI = { From 771a7969de788a6000e152c200b3112ea6dce8aa Mon Sep 17 00:00:00 2001 From: Alirie Gray Date: Thu, 21 Feb 2019 14:17:50 -0800 Subject: [PATCH 12/54] test(buckets): use aliases for bucket object --- ui/cypress/e2e/buckets.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/ui/cypress/e2e/buckets.ts b/ui/cypress/e2e/buckets.ts index 4cee4afe65..304e869c88 100644 --- a/ui/cypress/e2e/buckets.ts +++ b/ui/cypress/e2e/buckets.ts @@ -1,19 +1,21 @@ +import {Bucket} from '@influxdata/influx' + describe('Buckets', () => { - let orgID: string = '' - let bucketName: string = '' beforeEach(() => { cy.flush() cy.setupUser().then(({body}) => { - const {org, bucket} = body - orgID = org.id - bucketName = bucket.name + const { + org: {id}, + bucket, + } = body + cy.wrap(bucket).as('bucket') - cy.signin(orgID) - }) + cy.signin(id) - cy.fixture('routes').then(({orgs}) => { - cy.visit(`${orgs}/${orgID}/buckets_tab`) + cy.fixture('routes').then(({orgs}) => { + cy.visit(`${orgs}/${id}/buckets_tab`) + }) }) }) @@ -38,7 +40,9 @@ describe('Buckets', () => { it('can update a buckets name and retention rules', () => { const newName = 'newdefbuck' - cy.contains(bucketName).click() + cy.get('@bucket').then(({name}) => { + cy.contains(name).click() + }) cy.getByDataTest('retention-intervals').click() From ef86778bce256eed93b4326a3c4ad94429119f63 Mon Sep 17 00:00:00 2001 From: Alirie Gray Date: Thu, 21 Feb 2019 14:19:53 -0800 Subject: [PATCH 13/54] test(buckets): rename buckets test file --- ui/cypress/e2e/{buckets.ts => buckets.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ui/cypress/e2e/{buckets.ts => buckets.test.ts} (100%) diff --git a/ui/cypress/e2e/buckets.ts b/ui/cypress/e2e/buckets.test.ts similarity index 100% rename from ui/cypress/e2e/buckets.ts rename to ui/cypress/e2e/buckets.test.ts From 7fc8ca928db15977b7a9837cf4aa71b6041123d3 Mon Sep 17 00:00:00 2001 From: Alirie Gray Date: Thu, 21 Feb 2019 14:52:19 -0800 Subject: [PATCH 14/54] test(tasks): add test for task without valid script --- ui/cypress/e2e/tasks.ts | 32 ++++++++++++++++++++++++++--- ui/src/tasks/components/TaskRow.tsx | 2 +- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/ui/cypress/e2e/tasks.ts b/ui/cypress/e2e/tasks.ts index ef89ddbb10..79ff1a44b5 100644 --- a/ui/cypress/e2e/tasks.ts +++ b/ui/cypress/e2e/tasks.ts @@ -1,5 +1,4 @@ -// currently getting unauthorized errors for task creation -describe.skip('Tasks', () => { +describe('Tasks', () => { beforeEach(() => { cy.flush() @@ -11,11 +10,36 @@ describe.skip('Tasks', () => { }) it('can create a task', () => { + const taskName = '🦄ask' cy.get('.empty-state').within(() => { cy.contains('Create').click() }) - cy.getByInputName('name').type('🅱️ask') + cy.getByInputName('name').type(taskName) + cy.getByInputName('interval').type('1d') + cy.getByInputName('offset').type('20m') + + cy.getByDataTest('flux-editor').within(() => { + cy.get('textarea').type( + `from(bucket: "default") + |> range(start: -2m)`, + {force: true} + ) + }) + + cy.contains('Save').click() + + cy.getByDataTest('task-row') + .and('have.length', 1) + .and('contain', taskName) + }) + + it('fails to create a task without a valid script', () => { + cy.get('.empty-state').within(() => { + cy.contains('Create').click() + }) + + cy.getByInputName('name').type('🦄ask') cy.getByInputName('interval').type('1d') cy.getByInputName('offset').type('20m') @@ -24,5 +48,7 @@ describe.skip('Tasks', () => { }) cy.contains('Save').click() + + cy.getByDataTest('notification-error').should('exist') }) }) diff --git a/ui/src/tasks/components/TaskRow.tsx b/ui/src/tasks/components/TaskRow.tsx index d1de4624f5..62a52ca858 100644 --- a/ui/src/tasks/components/TaskRow.tsx +++ b/ui/src/tasks/components/TaskRow.tsx @@ -44,7 +44,7 @@ export class TaskRow extends PureComponent { const {task, onDelete} = this.props return ( - + Date: Thu, 21 Feb 2019 15:20:32 -0800 Subject: [PATCH 15/54] test(tasks): add test for deleting a task --- ui/cypress/e2e/tasks.ts | 20 +++++++++++++++- ui/cypress/index.d.ts | 2 ++ ui/cypress/support/commands.ts | 24 ++++++++++++++++++- .../ConfirmationButton.tsx | 4 ++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/ui/cypress/e2e/tasks.ts b/ui/cypress/e2e/tasks.ts index 79ff1a44b5..8deb53b1c3 100644 --- a/ui/cypress/e2e/tasks.ts +++ b/ui/cypress/e2e/tasks.ts @@ -1,9 +1,12 @@ +import {Organization} from '@influxdata/influx' + describe('Tasks', () => { beforeEach(() => { cy.flush() cy.setupUser().then(({body}) => { cy.signin(body.org.id) + cy.wrap(body.org).as('org') }) cy.visit('/tasks') @@ -30,10 +33,25 @@ describe('Tasks', () => { cy.contains('Save').click() cy.getByDataTest('task-row') - .and('have.length', 1) + .should('have.length', 1) .and('contain', taskName) }) + it('can delete a task', () => { + cy.get('@org').then(({id}) => { + cy.createTask(id) + cy.createTask(id) + }) + + cy.getByDataTest('task-row').should('have.length', 2) + + cy.getByDataTest('confirmation-button') + .first() + .click({force: true}) + + cy.getByDataTest('task-row').should('have.length', 1) + }) + it('fails to create a task without a valid script', () => { cy.get('.empty-state').within(() => { cy.contains('Create').click() diff --git a/ui/cypress/index.d.ts b/ui/cypress/index.d.ts index 19c1065d21..d971c49bdd 100644 --- a/ui/cypress/index.d.ts +++ b/ui/cypress/index.d.ts @@ -9,6 +9,7 @@ import { getByDataTest, getByInputName, getByTitle, + createTask } from './support/commands' declare global { @@ -17,6 +18,7 @@ declare global { signin: typeof signin setupUser: typeof setupUser createSource: typeof createSource + createTask: typeof createTask createDashboard: typeof createDashboard createOrg: typeof createOrg flush: typeof flush diff --git a/ui/cypress/support/commands.ts b/ui/cypress/support/commands.ts index cee5f26f28..ef946dc029 100644 --- a/ui/cypress/support/commands.ts +++ b/ui/cypress/support/commands.ts @@ -10,7 +10,6 @@ export const signin = (orgID?: string): Cypress.Chainable => { }) } -// createDashboard relies on an org fixture to be set export const createDashboard = ( orgID?: string ): Cypress.Chainable => { @@ -44,6 +43,26 @@ export const createBucket = (): Cypress.Chainable => { }) } +export const createTask = (orgID?: string): Cypress.Chainable => { + const flux = `option task = { + name: "🦄ask", + every: 1d, + offset: 20m + } + from(bucket: "default") + |> range(start: -2m)` + + return cy.request({ + method: 'POST', + url: '/api/v2/tasks', + body: { + flux, + orgID + } + }) +} + + export const createSource = ( orgID?: string ): Cypress.Chainable => { @@ -115,3 +134,6 @@ Cypress.Commands.add('createSource', createSource) // general Cypress.Commands.add('flush', flush) + +// tasks +Cypress.Commands.add('createTask', createTask) diff --git a/ui/src/clockface/components/confirmation_button/ConfirmationButton.tsx b/ui/src/clockface/components/confirmation_button/ConfirmationButton.tsx index 4e42251703..063e049c6b 100644 --- a/ui/src/clockface/components/confirmation_button/ConfirmationButton.tsx +++ b/ui/src/clockface/components/confirmation_button/ConfirmationButton.tsx @@ -33,6 +33,7 @@ interface Props { titleText?: string tabIndex?: number className?: string + testID?: string } interface State { @@ -45,6 +46,7 @@ class ConfirmationButton extends Component { size: ComponentSize.Small, shape: ButtonShape.Default, status: ComponentStatus.Default, + testID: 'confirmation-button', } public ref: RefObject = React.createRef() @@ -67,6 +69,7 @@ class ConfirmationButton extends Component { status, confirmText, icon, + testID, } = this.props const {isTooltipVisible} = this.state @@ -89,6 +92,7 @@ class ConfirmationButton extends Component {
From 6e2a9b14d43e7f632f3251ddefa63fabede4bb48 Mon Sep 17 00:00:00 2001 From: Andrew Watkins Date: Thu, 21 Feb 2019 15:56:35 -0800 Subject: [PATCH 16/54] test(ts): update snapshots --- .../test/__snapshots__/ConfirmationButton.test.tsx.snap | 2 ++ .../components/__snapshots__/Buckets.test.tsx.snap | 4 ++++ ui/src/tasks/components/__snapshots__/TaskRow.test.tsx.snap | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/src/clockface/components/confirmation_button/test/__snapshots__/ConfirmationButton.test.tsx.snap b/ui/src/clockface/components/confirmation_button/test/__snapshots__/ConfirmationButton.test.tsx.snap index d81ff7c783..eb253117e9 100644 --- a/ui/src/clockface/components/confirmation_button/test/__snapshots__/ConfirmationButton.test.tsx.snap +++ b/ui/src/clockface/components/confirmation_button/test/__snapshots__/ConfirmationButton.test.tsx.snap @@ -7,6 +7,7 @@ exports[`ConfirmationButton interaction shows the tooltip when clicked 1`] = ` shape="none" size="sm" status="default" + testID="confirmation-button" text="I am a dangerous button!" > Click me if you dare diff --git a/ui/src/organizations/components/__snapshots__/Buckets.test.tsx.snap b/ui/src/organizations/components/__snapshots__/Buckets.test.tsx.snap index b988a874ec..7a2b6f7f22 100644 --- a/ui/src/organizations/components/__snapshots__/Buckets.test.tsx.snap +++ b/ui/src/organizations/components/__snapshots__/Buckets.test.tsx.snap @@ -96,6 +96,7 @@ Object {
Confirm
@@ -228,6 +229,7 @@ Object {
Confirm
@@ -417,6 +419,7 @@ Object {
Confirm
@@ -549,6 +552,7 @@ Object {
Confirm
diff --git a/ui/src/tasks/components/__snapshots__/TaskRow.test.tsx.snap b/ui/src/tasks/components/__snapshots__/TaskRow.test.tsx.snap index f344a7c871..b2389d3184 100644 --- a/ui/src/tasks/components/__snapshots__/TaskRow.test.tsx.snap +++ b/ui/src/tasks/components/__snapshots__/TaskRow.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Tasks.Components.TaskRow renders 1`] = ` From ee518c5520556539e0196aabbc2529b5392a89e9 Mon Sep 17 00:00:00 2001 From: Andrew Watkins Date: Thu, 21 Feb 2019 16:08:03 -0800 Subject: [PATCH 17/54] chore: lint fix --- ui/cypress/index.d.ts | 2 +- ui/cypress/support/commands.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ui/cypress/index.d.ts b/ui/cypress/index.d.ts index d971c49bdd..d16a4f0c8e 100644 --- a/ui/cypress/index.d.ts +++ b/ui/cypress/index.d.ts @@ -9,7 +9,7 @@ import { getByDataTest, getByInputName, getByTitle, - createTask + createTask, } from './support/commands' declare global { diff --git a/ui/cypress/support/commands.ts b/ui/cypress/support/commands.ts index ef946dc029..477e409fab 100644 --- a/ui/cypress/support/commands.ts +++ b/ui/cypress/support/commands.ts @@ -43,8 +43,10 @@ export const createBucket = (): Cypress.Chainable => { }) } -export const createTask = (orgID?: string): Cypress.Chainable => { - const flux = `option task = { +export const createTask = ( + orgID?: string +): Cypress.Chainable => { + const flux = `option task = { name: "🦄ask", every: 1d, offset: 20m @@ -57,12 +59,11 @@ export const createTask = (orgID?: string): Cypress.Chainable url: '/api/v2/tasks', body: { flux, - orgID - } + orgID, + }, }) } - export const createSource = ( orgID?: string ): Cypress.Chainable => { From 08d092dafd76c60bdffadfaf109717037f789b61 Mon Sep 17 00:00:00 2001 From: Andrew Watkins Date: Thu, 21 Feb 2019 16:13:57 -0800 Subject: [PATCH 18/54] chore: change cypress npm run script --- ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/package.json b/ui/package.json index 427b599088..06ab2479cc 100644 --- a/ui/package.json +++ b/ui/package.json @@ -32,7 +32,7 @@ "tsc": "tsc -p ./tsconfig.json --noEmit --pretty --skipLibCheck", "tsc:watch": "tsc -p ./tsconfig.json --noEmit --pretty -w", "tsc:cypress": "tsc -p ./cypress/tsconfig.json --noEmit --pretty --skipLibCheck", - "cypress:open": "cypress open" + "e2e": "cypress open" }, "jest": { "setupFilesAfterEnv": [ From cc60a4df1d0a4c348e80d01ac19d2f7c255f26ec Mon Sep 17 00:00:00 2001 From: Andrew Watkins Date: Thu, 21 Feb 2019 16:14:40 -0800 Subject: [PATCH 19/54] test(tasks): skip e2e --- ui/cypress/e2e/tasks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/cypress/e2e/tasks.ts b/ui/cypress/e2e/tasks.ts index 8deb53b1c3..c0ce49f352 100644 --- a/ui/cypress/e2e/tasks.ts +++ b/ui/cypress/e2e/tasks.ts @@ -1,6 +1,6 @@ import {Organization} from '@influxdata/influx' -describe('Tasks', () => { +describe.skip('Tasks', () => { beforeEach(() => { cy.flush() From 3ce1078e045a29816809834c7f074d5eb2a7ea6b Mon Sep 17 00:00:00 2001 From: Andrew Watkins Date: Thu, 21 Feb 2019 17:38:11 -0800 Subject: [PATCH 20/54] chore(task): unskip task e2e --- ui/cypress/e2e/tasks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/cypress/e2e/tasks.ts b/ui/cypress/e2e/tasks.ts index c0ce49f352..8deb53b1c3 100644 --- a/ui/cypress/e2e/tasks.ts +++ b/ui/cypress/e2e/tasks.ts @@ -1,6 +1,6 @@ import {Organization} from '@influxdata/influx' -describe.skip('Tasks', () => { +describe('Tasks', () => { beforeEach(() => { cy.flush() From a36df9e54e8c16ad0227430d45ddf9f98ad1c2fd Mon Sep 17 00:00:00 2001 From: Andrew Watkins Date: Thu, 21 Feb 2019 17:38:57 -0800 Subject: [PATCH 21/54] chore: add test prefix to task test --- ui/cypress/e2e/{tasks.ts => tasks.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ui/cypress/e2e/{tasks.ts => tasks.test.ts} (100%) diff --git a/ui/cypress/e2e/tasks.ts b/ui/cypress/e2e/tasks.test.ts similarity index 100% rename from ui/cypress/e2e/tasks.ts rename to ui/cypress/e2e/tasks.test.ts From ad1bab1a3c7ec0fc37304c3bd7a5a0386e5c8426 Mon Sep 17 00:00:00 2001 From: zhulongcheng Date: Fri, 22 Feb 2019 11:51:20 +0800 Subject: [PATCH 22/54] fix(http): return an empty list of operation logs if not found --- http/org_service.go | 2 +- kv/bucket.go | 2 +- kv/dashboard.go | 2 +- kv/org.go | 2 +- kv/user.go | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/http/org_service.go b/http/org_service.go index 4d8edfae63..8fe70fc8f3 100644 --- a/http/org_service.go +++ b/http/org_service.go @@ -829,7 +829,7 @@ func newOrganizationLogResponse(id influxdb.ID, es []*influxdb.OperationLogEntry } return &operationLogResponse{ Links: map[string]string{ - "self": fmt.Sprintf("/api/v2/organizations/%s/log", id), + "self": fmt.Sprintf("/api/v2/orgs/%s/log", id), }, Log: log, } diff --git a/kv/bucket.go b/kv/bucket.go index 48eaa9ce0a..c0c6a1bd3d 100644 --- a/kv/bucket.go +++ b/kv/bucket.go @@ -699,7 +699,7 @@ func (s *Service) GetBucketOperationLog(ctx context.Context, id influxdb.ID, opt }) }) - if err != nil { + if err != nil && err != errKeyValueLogBoundsNotFound { return nil, 0, err } diff --git a/kv/dashboard.go b/kv/dashboard.go index f8df5e2b77..eaaafb3e0f 100644 --- a/kv/dashboard.go +++ b/kv/dashboard.go @@ -926,7 +926,7 @@ func (s *Service) GetDashboardOperationLog(ctx context.Context, id influxdb.ID, }) }) - if err != nil { + if err != nil && err != errKeyValueLogBoundsNotFound { return nil, 0, err } diff --git a/kv/org.go b/kv/org.go index 70f2579062..36371ccc07 100644 --- a/kv/org.go +++ b/kv/org.go @@ -476,7 +476,7 @@ func (s *Service) GetOrganizationOperationLog(ctx context.Context, id influxdb.I }) }) - if err != nil { + if err != nil && err != errKeyValueLogBoundsNotFound { return nil, 0, err } diff --git a/kv/user.go b/kv/user.go index b77f05c3b2..171578b19f 100644 --- a/kv/user.go +++ b/kv/user.go @@ -451,7 +451,7 @@ func (s *Service) GetUserOperationLog(ctx context.Context, id influxdb.ID, opts log := []*influxdb.OperationLogEntry{} err := s.kv.View(func(tx Tx) error { - key, err := encodeBucketOperationLogKey(id) + key, err := encodeUserOperationLogKey(id) if err != nil { return err } @@ -469,7 +469,7 @@ func (s *Service) GetUserOperationLog(ctx context.Context, id influxdb.ID, opts }) }) - if err != nil { + if err != nil && err != errKeyValueLogBoundsNotFound { return nil, 0, err } From e7a27243bea3c5373ef833cfc1bd3b11cb3a0aa9 Mon Sep 17 00:00:00 2001 From: Julius Volz Date: Fri, 22 Feb 2019 14:01:46 +0100 Subject: [PATCH 23/54] Check error when writing token file during setup Signed-off-by: Julius Volz --- cmd/influx/setup.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/influx/setup.go b/cmd/influx/setup.go index 7221eadc5f..c1694b64ff 100644 --- a/cmd/influx/setup.go +++ b/cmd/influx/setup.go @@ -71,7 +71,11 @@ func setupF(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to setup instance: %v", err) } - writeTokenToPath(result.Auth.Token, defaultTokenPath()) + err = writeTokenToPath(result.Auth.Token, defaultTokenPath()) + if err != nil { + return fmt.Errorf("failed to write token to path %q: %v", defaultTokenPath(), err) + } + fmt.Println(promptWithColor("Your token has been stored in "+defaultTokenPath()+".", colorCyan)) w := internal.NewTabWriter(os.Stdout) From 6fdcaf83b4375b09e24da8a41ca27beb2398e348 Mon Sep 17 00:00:00 2001 From: Mark Rushakoff Date: Thu, 21 Feb 2019 21:59:46 -0800 Subject: [PATCH 24/54] fix(swagger): quote keys named "y" The YAML parser used by the go-openapi libraries treats an unquoted y as a boolean key, which will lead to a difficult-to-understand parser error: types don't match expect map key string or int got: bool See also https://yaml.org/type/bool.html. --- http/swagger.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/http/swagger.yml b/http/swagger.yml index 5113196721..08f133b9fa 100644 --- a/http/swagger.yml +++ b/http/swagger.yml @@ -5819,7 +5819,7 @@ components: properties: x: $ref: '#/components/schemas/Axis' - y: + "y": # Quoted to prevent YAML parser from interpreting y as shorthand for true. $ref: '#/components/schemas/Axis' y2: $ref: '#/components/schemas/Axis' @@ -6051,7 +6051,7 @@ components: x: type: integer format: int32 - y: + "y": # Quoted to prevent YAML parser from interpreting y as shorthand for true. type: integer format: int32 w: @@ -6099,7 +6099,7 @@ components: x: type: integer format: int32 - y: + "y": # Quoted to prevent YAML parser from interpreting y as shorthand for true. type: integer format: int32 w: From c78477314abf6fbb5620ccbaf43986785962da96 Mon Sep 17 00:00:00 2001 From: Lyon Hill Date: Fri, 22 Feb 2019 09:47:04 -0700 Subject: [PATCH 25/54] Allow tasks to skip catchup (#12068) --- cmd/influxd/launcher/launcher.go | 2 +- task/backend/bolt/bolt.go | 29 +++++++++- task/backend/bolt/bolt_test.go | 92 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/cmd/influxd/launcher/launcher.go b/cmd/influxd/launcher/launcher.go index 0355a5194c..56aa622c8c 100644 --- a/cmd/influxd/launcher/launcher.go +++ b/cmd/influxd/launcher/launcher.go @@ -442,7 +442,7 @@ func (m *Launcher) run(ctx context.Context) (err error) { store taskbackend.Store err error ) - store, err = taskbolt.New(m.boltClient.DB(), "tasks") + store, err = taskbolt.New(m.boltClient.DB(), "tasks", taskbolt.NoCatchUp) if err != nil { m.logger.Error("failed opening task bolt", zap.Error(err)) return err diff --git a/task/backend/bolt/bolt.go b/task/backend/bolt/bolt.go index 05650b5e7d..a938622f92 100644 --- a/task/backend/bolt/bolt.go +++ b/task/backend/bolt/bolt.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "math" "time" bolt "github.com/coreos/bbolt" @@ -46,6 +47,8 @@ type Store struct { db *bolt.DB bucket []byte idGen platform.IDGenerator + + minLatestCompleted int64 } const basePath = "/tasks/v1/" @@ -59,8 +62,14 @@ var ( runIDs = []byte(basePath + "run_ids") ) +// Option is a optional configuration for the store. +type Option func(*Store) + +// NoCatchUp allows you to skip any task that was supposed to run during down time. +func NoCatchUp(st *Store) { st.minLatestCompleted = time.Now().Unix() } + // New gives us a new Store based on "github.com/coreos/bbolt" -func New(db *bolt.DB, rootBucket string) (*Store, error) { +func New(db *bolt.DB, rootBucket string, opts ...Option) (*Store, error) { if db.IsReadOnly() { return nil, ErrDBReadOnly } @@ -87,7 +96,11 @@ func New(db *bolt.DB, rootBucket string) (*Store, error) { if err != nil { return nil, err } - return &Store{db: db, bucket: bucket, idGen: snowflake.NewDefaultIDGenerator()}, nil + st := &Store{db: db, bucket: bucket, idGen: snowflake.NewDefaultIDGenerator(), minLatestCompleted: math.MinInt64} + for _, opt := range opts { + opt(st) + } + return st, nil } // CreateTask creates a task in the boltdb task store. @@ -434,6 +447,10 @@ func (s *Store) FindTaskMetaByID(ctx context.Context, id platform.ID) (*backend. return nil, err } + if stm.LatestCompleted < s.minLatestCompleted { + stm.LatestCompleted = s.minLatestCompleted + } + return &stm, nil } @@ -472,6 +489,10 @@ func (s *Store) FindTaskByIDWithMeta(ctx context.Context, id platform.ID) (*back return nil, nil, err } + if stm.LatestCompleted < s.minLatestCompleted { + stm.LatestCompleted = s.minLatestCompleted + } + return &backend.StoreTask{ ID: id, Org: orgID, @@ -539,6 +560,10 @@ func (s *Store) CreateNextRun(ctx context.Context, taskID platform.ID, now int64 return err } + if stm.LatestCompleted < s.minLatestCompleted { + stm.LatestCompleted = s.minLatestCompleted + } + rc, err = stm.CreateNextRun(now, func() (platform.ID, error) { return s.idGen.ID(), nil }) diff --git a/task/backend/bolt/bolt_test.go b/task/backend/bolt/bolt_test.go index c86f26d93e..bb9c9ba3ca 100644 --- a/task/backend/bolt/bolt_test.go +++ b/task/backend/bolt/bolt_test.go @@ -1,11 +1,14 @@ package bolt_test import ( + "context" "io/ioutil" "os" "testing" + "time" bolt "github.com/coreos/bbolt" + "github.com/influxdata/influxdb" _ "github.com/influxdata/influxdb/query/builtin" "github.com/influxdata/influxdb/task/backend" boltstore "github.com/influxdata/influxdb/task/backend/bolt" @@ -49,3 +52,92 @@ func TestBoltStore(t *testing.T) { }, )(t) } + +func TestSkip(t *testing.T) { + f, err := ioutil.TempFile("", "influx_bolt_task_store_test") + if err != nil { + t.Fatalf("failed to create tempfile for test db %v\n", err) + } + defer f.Close() + defer os.Remove(f.Name()) + + db, err := bolt.Open(f.Name(), os.ModeTemporary, nil) + if err != nil { + t.Fatalf("failed to open bolt db for test db %v\n", err) + } + s, err := boltstore.New(db, "testbucket") + if err != nil { + t.Fatalf("failed to create new bolt store %v\n", err) + } + + schedAfter := time.Now().Add(-time.Minute) + tskID, err := s.CreateTask(context.Background(), backend.CreateTaskRequest{ + Org: influxdb.ID(1), + AuthorizationID: influxdb.ID(2), + Script: `option task = {name:"x", every:1s} from(bucket:"b-src") |> range(start:-1m) |> to(bucket:"b-dst", org:"o")`, + ScheduleAfter: schedAfter.Unix(), + Status: backend.TaskActive, + }) + if err != nil { + t.Fatalf("failed to create new task %v\n", err) + } + + rc, err := s.CreateNextRun(context.Background(), tskID, schedAfter.Add(10*time.Second).Unix()) + if err != nil { + t.Fatalf("failed to create new run %v\n", err) + } + + if err := s.FinishRun(context.Background(), tskID, rc.Created.RunID); err != nil { + t.Fatalf("failed to finish run %v\n", err) + } + + meta, err := s.FindTaskMetaByID(context.Background(), tskID) + if err != nil { + t.Fatalf("failed to pull meta %v\n", err) + } + if meta.LatestCompleted <= schedAfter.Unix() { + t.Fatal("failed to update latestCompleted") + } + + latestCompleted := meta.LatestCompleted + + s.Close() + + db, err = bolt.Open(f.Name(), os.ModeTemporary, nil) + if err != nil { + t.Fatalf("failed to open bolt db for test db %v\n", err) + } + s, err = boltstore.New(db, "testbucket", boltstore.NoCatchUp) + if err != nil { + t.Fatalf("failed to create new bolt store %v\n", err) + } + defer s.Close() + + meta, err = s.FindTaskMetaByID(context.Background(), tskID) + if err != nil { + t.Fatalf("failed to pull meta %v\n", err) + } + + if meta.LatestCompleted == latestCompleted { + t.Fatal("failed to overwrite latest completed on new meta pull") + } + latestCompleted = meta.LatestCompleted + + rc, err = s.CreateNextRun(context.Background(), tskID, time.Now().Add(10*time.Second).Unix()) + if err != nil { + t.Fatalf("failed to create new run %v\n", err) + } + + if err := s.FinishRun(context.Background(), tskID, rc.Created.RunID); err != nil { + t.Fatalf("failed to finish run %v\n", err) + } + + meta, err = s.FindTaskMetaByID(context.Background(), tskID) + if err != nil { + t.Fatalf("failed to pull meta %v\n", err) + } + + if meta.LatestCompleted == latestCompleted { + t.Fatal("failed to run after an override") + } +} From 12a604172f764d203d4ec71724c4c04c0127efa2 Mon Sep 17 00:00:00 2001 From: Mark Rushakoff Date: Thu, 21 Feb 2019 14:21:36 -0800 Subject: [PATCH 26/54] fix(task): create authorizations for tasks, which can read their task Also set the generated token's description while we're here. This enables us to use task's Authorization when we need to query the system bucket to get run logs, etc. but we only have a Session. --- http/task_service.go | 86 +++++++++++++++++++++++++++++++++++---- http/task_service_test.go | 67 ++++++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 11 deletions(-) diff --git a/http/task_service.go b/http/task_service.go index aa93d30169..9641a73df7 100644 --- a/http/task_service.go +++ b/http/task_service.go @@ -19,6 +19,7 @@ import ( pcontext "github.com/influxdata/influxdb/context" "github.com/influxdata/influxdb/query" "github.com/influxdata/influxdb/task/backend" + "github.com/influxdata/influxdb/task/options" "github.com/julienschmidt/httprouter" "go.uber.org/zap" ) @@ -369,44 +370,98 @@ func decodeGetTasksRequest(ctx context.Context, r *http.Request) (*getTasksReque return req, nil } -func (h *TaskHandler) createTaskAuthorizationIfNotExists(ctx context.Context, a platform.Authorizer, t *platform.TaskCreate) error { +// createBootstrapTaskAuthorizationIfNotExists checks if a the task create request hasn't specified a token, and if the request came from a session, +// and if both of those are true, it creates an authorization and return it. +// +// Note that the created authorization will have permissions required for the task, +// but it won't have permissions to read the task, as we don't have the task ID yet. +// +// This method may return a nil error and a nil authorization, if there wasn't a need to create an authorization. +func (h *TaskHandler) createBootstrapTaskAuthorizationIfNotExists(ctx context.Context, a platform.Authorizer, t *platform.TaskCreate) (*platform.Authorization, error) { if t.Token != "" { - return nil + return nil, nil } s, ok := a.(*platform.Session) if !ok { // If an authorization was used continue - return nil + return nil, nil } spec, err := flux.Compile(ctx, t.Flux, time.Now()) if err != nil { - return err + return nil, err } preAuthorizer := query.NewPreAuthorizer(h.BucketService) ps, err := preAuthorizer.RequiredPermissions(ctx, spec) if err != nil { - return err + return nil, err } if err := authorizer.VerifyPermissions(ctx, ps); err != nil { - return err + return nil, err + } + + opts, err := options.FromScript(t.Flux) + if err != nil { + return nil, err } auth := &platform.Authorization{ OrgID: t.OrganizationID, UserID: s.UserID, Permissions: ps, + Description: fmt.Sprintf("bootstrap authorization for task %q", opts.Name), } if err := h.AuthorizationService.CreateAuthorization(ctx, auth); err != nil { - return err + return nil, err } t.Token = auth.Token + return auth, nil +} + +func (h *TaskHandler) finalizeBootstrappedTaskAuthorization(ctx context.Context, bootstrap *platform.Authorization, task *platform.Task) error { + // If we created a bootstrapped authorization for a task, + // we need to replace it with a new authorization that allows read access on the task. + // Unfortunately for this case, updating authorizations is not allowed. + readTaskPerm, err := platform.NewPermissionAtID(task.ID, platform.ReadAction, platform.TasksResourceType, bootstrap.OrgID) + if err != nil { + // We should never fail to create a new permission like this. + return err + } + authzWithTask := &platform.Authorization{ + UserID: bootstrap.UserID, + OrgID: bootstrap.OrgID, + Permissions: append([]platform.Permission{*readTaskPerm}, bootstrap.Permissions...), + Description: fmt.Sprintf("auto-generated authorization for task %q", task.Name), + } + + if err := h.AuthorizationService.CreateAuthorization(ctx, authzWithTask); err != nil { + h.logger.Warn("Failed to finalize bootstrap authorization", zap.String("taskID", task.ID.String())) + // The task exists with an authorization that can't read the task. + return err + } + + // Assign the new authorization... + u, err := h.TaskService.UpdateTask(ctx, task.ID, platform.TaskUpdate{Token: authzWithTask.Token}) + if err != nil { + h.logger.Warn("Failed to assign finalized authorization", zap.String("authorizationID", bootstrap.ID.String()), zap.String("taskID", task.ID.String())) + // The task exists with an authorization that can't read the task, + // and we've created a new authorization for the task but not assigned it. + return err + } + *task = *u + + // .. and delete the old one. + if err := h.AuthorizationService.DeleteAuthorization(ctx, bootstrap.ID); err != nil { + // Since this is the last thing we're doing, just log it if we fail to delete for some reason. + h.logger.Warn("Failed to delete bootstrap authorization", zap.String("authorizationID", bootstrap.ID.String()), zap.String("taskID", task.ID.String())) + } + return nil } @@ -453,7 +508,8 @@ func (h *TaskHandler) handlePostTask(w http.ResponseWriter, r *http.Request) { return } - if err := h.createTaskAuthorizationIfNotExists(ctx, auth, &req.TaskCreate); err != nil { + bootstrapAuthz, err := h.createBootstrapTaskAuthorizationIfNotExists(ctx, auth, &req.TaskCreate) + if err != nil { EncodeError(ctx, err, w) return } @@ -471,6 +527,20 @@ func (h *TaskHandler) handlePostTask(w http.ResponseWriter, r *http.Request) { return } + if bootstrapAuthz != nil { + // There was a bootstrapped authorization for this task. + // Now we need to apply the final authorization for the task. + if err := h.finalizeBootstrappedTaskAuthorization(ctx, bootstrapAuthz, task); err != nil { + err = &platform.Error{ + Err: err, + Msg: fmt.Sprintf("successfully created task with ID %s, but failed to finalize bootstrap token for task", task.ID.String()), + Code: platform.EInternal, + } + EncodeError(ctx, err, w) + return + } + } + if err := encodeResponse(ctx, w, http.StatusCreated, newTaskResponse(*task, []*platform.Label{})); err != nil { logEncodingError(h.logger, r, err) return diff --git a/http/task_service_test.go b/http/task_service_test.go index 131e02cb94..5778f8a2ef 100644 --- a/http/task_service_test.go +++ b/http/task_service_test.go @@ -900,16 +900,27 @@ func TestService_handlePostTaskLabel(t *testing.T) { } func TestTaskHandler_CreateTaskFromSession(t *testing.T) { + i := inmem.NewService() + + taskID := platform.ID(9) var createdTasks []platform.TaskCreate ts := &mock.TaskService{ CreateTaskFn: func(_ context.Context, tc platform.TaskCreate) (*platform.Task, error) { createdTasks = append(createdTasks, tc) // Task with fake IDs so it can be serialized. - return &platform.Task{ID: 9, OrganizationID: 99, AuthorizationID: 999}, nil + return &platform.Task{ID: taskID, OrganizationID: 99, AuthorizationID: 999, Name: "x"}, nil + }, + // Needed due to task authorization bootstrapping. + UpdateTaskFn: func(ctx context.Context, id platform.ID, tu platform.TaskUpdate) (*platform.Task, error) { + authz, err := i.FindAuthorizationByToken(ctx, tu.Token) + if err != nil { + t.Fatal(err) + } + + return &platform.Task{ID: taskID, OrganizationID: 99, AuthorizationID: authz.ID, Name: "x"}, nil }, } - i := inmem.NewService() h := NewTaskHandler(&TaskBackend{ Logger: zaptest.NewLogger(t), @@ -992,7 +1003,57 @@ func TestTaskHandler_CreateTaskFromSession(t *testing.T) { } // The task should have been created with a valid token. - if _, err := i.FindAuthorizationByToken(ctx, createdTasks[0].Token); err != nil { + var createdTask platform.Task + if err := json.Unmarshal([]byte(body), &createdTask); err != nil { t.Fatal(err) } + authz, err := i.FindAuthorizationByID(ctx, createdTask.AuthorizationID) + if err != nil { + t.Fatal(err) + } + if authz.UserID != u.ID { + t.Fatalf("expected authorization to be associated with user %v, got %v", u.ID, authz.UserID) + } + if authz.OrgID != o.ID { + t.Fatalf("expected authorization to be associated with org %v, got %v", o.ID, authz.OrgID) + } + const expDesc = `auto-generated authorization for task "x"` + if authz.Description != expDesc { + t.Fatalf("expected authorization to be created with description %q, got %q", expDesc, authz.Description) + } + + // The authorization should be allowed to read and write the target buckets, + // and it should be allowed to read its task. + if !authz.Allowed(platform.Permission{ + Action: platform.ReadAction, + Resource: platform.Resource{ + Type: platform.BucketsResourceType, + OrgID: &o.ID, + ID: &bSrc.ID, + }, + }) { + t.Logf("WARNING: permissions on `from` buckets not yet accessible: update test after https://github.com/influxdata/flux/issues/114 is fixed.") + } + + if !authz.Allowed(platform.Permission{ + Action: platform.WriteAction, + Resource: platform.Resource{ + Type: platform.BucketsResourceType, + OrgID: &o.ID, + ID: &bDst.ID, + }, + }) { + t.Fatalf("expected authorization to be allowed write access to destination bucket, but it wasn't allowed") + } + + if !authz.Allowed(platform.Permission{ + Action: platform.ReadAction, + Resource: platform.Resource{ + Type: platform.TasksResourceType, + OrgID: &o.ID, + ID: &taskID, + }, + }) { + t.Fatalf("expected authorization to be allowed to read its task, but it wasn't allowed") + } } From a75adf6c4b4c1857583fca2bd243e368e6d00dab Mon Sep 17 00:00:00 2001 From: Nathaniel Cook Date: Fri, 22 Feb 2019 10:11:41 -0700 Subject: [PATCH 27/54] fix(http): separate InfluxQL and Flux services Previously the APIBackend understood only a ProxyQueryService, but it needs to understand that there are two implementations of the ProxyQueryService one for handling InfluxQL queries and one for handling Flux queries. The names of the fields have been updated to make this clear. As well as the FluxBackend is now initialized using the FluxService explicitly. --- cmd/influxd/launcher/launcher.go | 3 ++- http/api_handler.go | 3 ++- http/query_handler.go | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd/influxd/launcher/launcher.go b/cmd/influxd/launcher/launcher.go index 56aa622c8c..d7883823ab 100644 --- a/cmd/influxd/launcher/launcher.go +++ b/cmd/influxd/launcher/launcher.go @@ -535,7 +535,8 @@ func (m *Launcher) run(ctx context.Context) (err error) { VariableService: variableSvc, PasswordsService: passwdsSvc, OnboardingService: onboardingSvc, - ProxyQueryService: storageQueryService, + InfluxQLService: nil, // No InfluxQL support + FluxService: storageQueryService, TaskService: taskSvc, TelegrafService: telegrafSvc, ScraperTargetStoreService: scraperTargetSvc, diff --git a/http/api_handler.go b/http/api_handler.go index 7b5d0d81be..f9888c5b96 100644 --- a/http/api_handler.go +++ b/http/api_handler.go @@ -61,7 +61,8 @@ type APIBackend struct { VariableService influxdb.VariableService PasswordsService influxdb.PasswordsService OnboardingService influxdb.OnboardingService - ProxyQueryService query.ProxyQueryService + InfluxQLService query.ProxyQueryService + FluxService query.ProxyQueryService TaskService influxdb.TaskService TelegrafService influxdb.TelegrafConfigStore ScraperTargetStoreService influxdb.ScraperTargetStoreService diff --git a/http/query_handler.go b/http/query_handler.go index 9318242fdf..64c417f604 100644 --- a/http/query_handler.go +++ b/http/query_handler.go @@ -42,7 +42,7 @@ func NewFluxBackend(b *APIBackend) *FluxBackend { return &FluxBackend{ Logger: b.Logger.With(zap.String("handler", "query")), - ProxyQueryService: b.ProxyQueryService, + ProxyQueryService: b.FluxService, OrganizationService: b.OrganizationService, } } From ed39dc5e4622662d90640995e7f392b358389914 Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Thu, 21 Feb 2019 11:18:26 -0800 Subject: [PATCH 28/54] feat(ui): Clone a task's labels when cloning the task Move task cloning logic to external client library --- CHANGELOG.md | 1 + ui/package-lock.json | 47 ++++++++++++++++++++++---------- ui/package.json | 2 +- ui/src/tasks/actions/v2/index.ts | 4 +-- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce9bfd7c1c..7f29fb633f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## v2.0.0-alpha.5 [unreleased] ### Features +1. [12096](https://github.com/influxdata/influxdb/pull/12096): Add labels to cloned tasks ### Bug Fixes diff --git a/ui/package-lock.json b/ui/package-lock.json index 0b16870c24..6d4b1ddfe6 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -985,9 +985,9 @@ } }, "@influxdata/influx": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/@influxdata/influx/-/influx-0.2.15.tgz", - "integrity": "sha512-4s3yLEYdiauq0eydi35GrxTOs55ghpRiBiNFKuH5kTGOrXj9y9OSxJfMLyE+Dy4s4FD/Z+UpeBM2Uy3dRdzerg==", + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@influxdata/influx/-/influx-0.2.18.tgz", + "integrity": "sha512-GMkSinELOnOJMuupd/7H4CwOEWqvVTj3863tgH/b7HBRDSZyi/FqYUPazEYSoMXPQA65oPhcqnbgYAUtC1foWw==", "requires": { "axios": "^0.18.0" } @@ -6085,7 +6085,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -6109,13 +6110,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6132,19 +6135,22 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -6275,7 +6281,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -6289,6 +6296,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -6305,6 +6313,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -6313,13 +6322,15 @@ "version": "0.0.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.2.4.tgz", "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==", "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -6340,6 +6351,7 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -6428,7 +6440,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -6442,6 +6455,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -6537,7 +6551,8 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -6579,6 +6594,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -6600,6 +6616,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -6648,13 +6665,15 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz", "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=", - "dev": true + "dev": true, + "optional": true } } }, diff --git a/ui/package.json b/ui/package.json index 1ca5f9d38f..0e123e6d14 100644 --- a/ui/package.json +++ b/ui/package.json @@ -134,7 +134,7 @@ }, "dependencies": { "@influxdata/clockface": "0.0.5", - "@influxdata/influx": "0.2.15", + "@influxdata/influx": "0.2.18", "@influxdata/react-custom-scrollbars": "4.3.8", "axios": "^0.18.0", "babel-polyfill": "^6.26.0", diff --git a/ui/src/tasks/actions/v2/index.ts b/ui/src/tasks/actions/v2/index.ts index 8a5b4e1965..8bfd97d1b2 100644 --- a/ui/src/tasks/actions/v2/index.ts +++ b/ui/src/tasks/actions/v2/index.ts @@ -249,9 +249,7 @@ export const deleteTask = (task: Task) => async dispatch => { export const cloneTask = (task: Task, _) => async dispatch => { try { - // const allTaskNames = tasks.map(t => t.name) - // const clonedName = incrementCloneName(allTaskNames, task.name) - await client.tasks.create(task.orgID, task.flux) + await client.tasks.clone(task.id) dispatch(notify(taskCloneSuccess(task.name))) dispatch(populateTasks()) From 65be548a58e1f833a1b57d178b490375b0f90349 Mon Sep 17 00:00:00 2001 From: Michael Desa Date: Fri, 22 Feb 2019 14:04:01 -0500 Subject: [PATCH 29/54] feat(kv): make user owner of org/dashboard on create --- kv/dashboard.go | 17 ++++++++++++++++- kv/org.go | 19 ++++++++++++++++++- kv/urm.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/kv/dashboard.go b/kv/dashboard.go index f8df5e2b77..7d9a93deb3 100644 --- a/kv/dashboard.go +++ b/kv/dashboard.go @@ -8,6 +8,7 @@ import ( influxdb "github.com/influxdata/influxdb" icontext "github.com/influxdata/influxdb/context" + "go.uber.org/zap" ) var ( @@ -301,7 +302,15 @@ func (s *Service) CreateDashboard(ctx context.Context, d *influxdb.Dashboard) er // TODO(desa): don't populate this here. use the first/last methods of the oplog to get meta fields. d.Meta.CreatedAt = s.time() - return s.putDashboardWithMeta(ctx, tx, d) + if err := s.putDashboardWithMeta(ctx, tx, d); err != nil { + return err + } + + if err := s.addDashboardOwner(ctx, tx, d.ID); err != nil { + s.Logger.Info("failed to make user owner of organization", zap.Error(err)) + } + + return nil }) if err != nil { return &influxdb.Error{ @@ -311,6 +320,12 @@ func (s *Service) CreateDashboard(ctx context.Context, d *influxdb.Dashboard) er return nil } +// addDashboardOwner attempts to create a user resource mapping for the user on the +// authorizer found on context. If no authorizer is found on context if returns an error. +func (s *Service) addDashboardOwner(ctx context.Context, tx Tx, orgID influxdb.ID) error { + return s.addResourceOwner(ctx, tx, influxdb.DashboardsResourceType, orgID) +} + func (s *Service) createCellView(ctx context.Context, tx Tx, dashID, cellID influxdb.ID, view *influxdb.View) error { if view == nil { // If not view exists create the view diff --git a/kv/org.go b/kv/org.go index 70f2579062..33105d4552 100644 --- a/kv/org.go +++ b/kv/org.go @@ -8,6 +8,7 @@ import ( influxdb "github.com/influxdata/influxdb" icontext "github.com/influxdata/influxdb/context" + "go.uber.org/zap" ) var ( @@ -213,10 +214,26 @@ func (s *Service) FindOrganizations(ctx context.Context, filter influxdb.Organiz // CreateOrganization creates a influxdb organization and sets b.ID. func (s *Service) CreateOrganization(ctx context.Context, o *influxdb.Organization) error { return s.kv.Update(func(tx Tx) error { - return s.createOrganization(ctx, tx, o) + if err := s.createOrganization(ctx, tx, o); err != nil { + return err + } + + // Attempt to add user as owner of organization, if that is not possible allow the + // organization to be created anyways. + if err := s.addOrgOwner(ctx, tx, o.ID); err != nil { + s.Logger.Info("failed to make user owner of organization", zap.Error(err)) + } + + return nil }) } +// addOrgOwner attempts to create a user resource mapping for the user on the +// authorizer found on context. If no authorizer is found on context if returns an error. +func (s *Service) addOrgOwner(ctx context.Context, tx Tx, orgID influxdb.ID) error { + return s.addResourceOwner(ctx, tx, influxdb.OrgsResourceType, orgID) +} + func (s *Service) createOrganization(ctx context.Context, tx Tx, o *influxdb.Organization) error { if err := s.uniqueOrganizationName(ctx, tx, o); err != nil { return err diff --git a/kv/urm.go b/kv/urm.go index b4e0e41106..bd11e751a2 100644 --- a/kv/urm.go +++ b/kv/urm.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/influxdata/influxdb" + icontext "github.com/influxdata/influxdb/context" ) var ( @@ -358,3 +359,30 @@ func (s *Service) deleteOrgDependentMappings(ctx context.Context, tx Tx, m *infl return nil } + +func (s *Service) addResourceOwner(ctx context.Context, tx Tx, rt influxdb.ResourceType, id influxdb.ID) error { + a, err := icontext.GetAuthorizer(ctx) + if err != nil { + return &influxdb.Error{ + Code: influxdb.EInternal, + Msg: fmt.Sprintf("could not find authorizer on context when adding user to resource type %s", rt), + } + } + + urm := &influxdb.UserResourceMapping{ + ResourceID: id, + ResourceType: rt, + UserID: a.GetUserID(), + UserType: influxdb.Owner, + } + + if err := s.createUserResourceMapping(ctx, tx, urm); err != nil { + return &influxdb.Error{ + Code: influxdb.EInternal, + Msg: "could not create user resource mapping", + Err: err, + } + } + + return nil +} From f79d9cba4fca7a294baa41f65f3f0e6c83cf23b6 Mon Sep 17 00:00:00 2001 From: Mark Rushakoff Date: Wed, 20 Feb 2019 15:49:55 -0800 Subject: [PATCH 30/54] fix(task): pass task's authorization to query system, if using sessions The query system specifically expects an Authorization. When a request comes in using a Session, use the target task's Authorization, if we are allowed to read it, when executing a query against the system bucket. --- http/task_service.go | 121 ++++++++ http/task_service_test.go | 638 +++++++++++++++++++++++++++++++------- 2 files changed, 646 insertions(+), 113 deletions(-) diff --git a/http/task_service.go b/http/task_service.go index 9641a73df7..c3f419e977 100644 --- a/http/task_service.go +++ b/http/task_service.go @@ -769,6 +769,29 @@ func (h *TaskHandler) handleGetLogs(w http.ResponseWriter, r *http.Request) { return } + auth, err := pcontext.GetAuthorizer(ctx) + if err != nil { + err = &platform.Error{ + Err: err, + Code: platform.EUnauthorized, + Msg: "failed to get authorizer", + } + EncodeError(ctx, err, w) + return + } + + if k := auth.Kind(); k != platform.AuthorizationKind { + // Get the authorization for the task, if allowed. + authz, err := h.getAuthorizationForTask(ctx, req.filter.Task) + if err != nil { + EncodeError(ctx, err, w) + return + } + + // We were able to access the authorizer for the task, so reassign that on the context for the rest of this call. + ctx = pcontext.SetAuthorizer(ctx, authz) + } + logs, _, err := h.TaskService.FindLogs(ctx, req.filter) if err != nil { err := &platform.Error{ @@ -834,6 +857,29 @@ func (h *TaskHandler) handleGetRuns(w http.ResponseWriter, r *http.Request) { return } + auth, err := pcontext.GetAuthorizer(ctx) + if err != nil { + err = &platform.Error{ + Err: err, + Code: platform.EUnauthorized, + Msg: "failed to get authorizer", + } + EncodeError(ctx, err, w) + return + } + + if k := auth.Kind(); k != platform.AuthorizationKind { + // Get the authorization for the task, if allowed. + authz, err := h.getAuthorizationForTask(ctx, req.filter.Task) + if err != nil { + EncodeError(ctx, err, w) + return + } + + // We were able to access the authorizer for the task, so reassign that on the context for the rest of this call. + ctx = pcontext.SetAuthorizer(ctx, authz) + } + runs, _, err := h.TaskService.FindRuns(ctx, req.filter) if err != nil { err := &platform.Error{ @@ -1018,6 +1064,29 @@ func (h *TaskHandler) handleGetRun(w http.ResponseWriter, r *http.Request) { return } + auth, err := pcontext.GetAuthorizer(ctx) + if err != nil { + err = &platform.Error{ + Err: err, + Code: platform.EUnauthorized, + Msg: "failed to get authorizer", + } + EncodeError(ctx, err, w) + return + } + + if k := auth.Kind(); k != platform.AuthorizationKind { + // Get the authorization for the task, if allowed. + authz, err := h.getAuthorizationForTask(ctx, req.TaskID) + if err != nil { + EncodeError(ctx, err, w) + return + } + + // We were able to access the authorizer for the task, so reassign that on the context for the rest of this call. + ctx = pcontext.SetAuthorizer(ctx, authz) + } + run, err := h.TaskService.FindRunByID(ctx, req.TaskID, req.RunID) if err != nil { err := &platform.Error{ @@ -1152,6 +1221,29 @@ func (h *TaskHandler) handleRetryRun(w http.ResponseWriter, r *http.Request) { return } + auth, err := pcontext.GetAuthorizer(ctx) + if err != nil { + err = &platform.Error{ + Err: err, + Code: platform.EUnauthorized, + Msg: "failed to get authorizer", + } + EncodeError(ctx, err, w) + return + } + + if k := auth.Kind(); k != platform.AuthorizationKind { + // Get the authorization for the task, if allowed. + authz, err := h.getAuthorizationForTask(ctx, req.TaskID) + if err != nil { + EncodeError(ctx, err, w) + return + } + + // We were able to access the authorizer for the task, so reassign that on the context for the rest of this call. + ctx = pcontext.SetAuthorizer(ctx, authz) + } + run, err := h.TaskService.RetryRun(ctx, req.TaskID, req.RunID) if err != nil { err := &platform.Error{ @@ -1230,6 +1322,35 @@ func (h *TaskHandler) populateTaskCreateOrg(ctx context.Context, tc *platform.Ta return nil } +// getAuthorizationForTask looks up the authorization associated with taskID, +// ensuring that the authorizer on ctx is allowed to view the task and the authorization. +// +// This method returns a *platform.Error, suitable for directly passing to EncodeError. +func (h *TaskHandler) getAuthorizationForTask(ctx context.Context, taskID platform.ID) (*platform.Authorization, *platform.Error) { + // First look up the task, if we're allowed. + // This assumes h.TaskService validates access. + t, err := h.TaskService.FindTaskByID(ctx, taskID) + if err != nil { + return nil, &platform.Error{ + Err: err, + Code: platform.EUnauthorized, + Msg: "task ID unknown or unauthorized", + } + } + + // Explicitly check against an authorized authorization service. + authz, err := authorizer.NewAuthorizationService(h.AuthorizationService).FindAuthorizationByID(ctx, t.AuthorizationID) + if err != nil { + return nil, &platform.Error{ + Err: err, + Code: platform.EUnauthorized, + Msg: "unable to access task authorization", + } + } + + return authz, nil +} + // TaskService connects to Influx via HTTP using tokens to manage tasks. type TaskService struct { Addr string diff --git a/http/task_service_test.go b/http/task_service_test.go index 5778f8a2ef..d3d39f38f5 100644 --- a/http/task_service_test.go +++ b/http/task_service_test.go @@ -377,7 +377,7 @@ func TestTaskHandler_handleGetRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { r := httptest.NewRequest("GET", "http://any.url", nil) r = r.WithContext(context.WithValue( - context.TODO(), + context.Background(), httprouter.ParamsKey, httprouter.Params{ { @@ -389,6 +389,7 @@ func TestTaskHandler_handleGetRun(t *testing.T) { Value: tt.args.runID.String(), }, })) + r = r.WithContext(pcontext.SetAuthorizer(r.Context(), &platform.Authorization{Permissions: platform.OperPermissions()})) w := httptest.NewRecorder() taskBackend := NewMockTaskBackend(t) taskBackend.TaskService = tt.fields.taskService @@ -490,7 +491,7 @@ func TestTaskHandler_handleGetRuns(t *testing.T) { t.Run(tt.name, func(t *testing.T) { r := httptest.NewRequest("GET", "http://any.url", nil) r = r.WithContext(context.WithValue( - context.TODO(), + context.Background(), httprouter.ParamsKey, httprouter.Params{ { @@ -498,6 +499,7 @@ func TestTaskHandler_handleGetRuns(t *testing.T) { Value: tt.args.taskID.String(), }, })) + r = r.WithContext(pcontext.SetAuthorizer(r.Context(), &platform.Authorization{Permissions: platform.OperPermissions()})) w := httptest.NewRecorder() taskBackend := NewMockTaskBackend(t) taskBackend.TaskService = tt.fields.taskService @@ -538,6 +540,9 @@ func TestTaskHandler_NotFoundStatus(t *testing.T) { t.Fatal(err) } + // Create a session to associate with the contexts, so authorization checks pass. + authz := &platform.Authorization{Permissions: platform.OperPermissions()} + const taskID, runID = platform.ID(0xCCCCCC), platform.ID(0xAAAAAA) var ( @@ -763,7 +768,9 @@ func TestTaskHandler_NotFoundStatus(t *testing.T) { okPath := fmt.Sprintf(tc.pathFmt, tc.okPathArgs...) t.Run("matching ID: "+tc.method+" "+okPath, func(t *testing.T) { w := httptest.NewRecorder() - r := httptest.NewRequest(tc.method, "http://task.example/api/v2"+okPath, strings.NewReader(tc.body)) + r := httptest.NewRequest(tc.method, "http://task.example/api/v2"+okPath, strings.NewReader(tc.body)).WithContext( + pcontext.SetAuthorizer(context.Background(), authz), + ) h.ServeHTTP(w, r) @@ -782,7 +789,9 @@ func TestTaskHandler_NotFoundStatus(t *testing.T) { path := fmt.Sprintf(tc.pathFmt, nfa...) t.Run(tc.method+" "+path, func(t *testing.T) { w := httptest.NewRecorder() - r := httptest.NewRequest(tc.method, "http://task.example/api/v2"+path, strings.NewReader(tc.body)) + r := httptest.NewRequest(tc.method, "http://task.example/api/v2"+path, strings.NewReader(tc.body)).WithContext( + pcontext.SetAuthorizer(context.Background(), authz), + ) h.ServeHTTP(w, r) @@ -899,40 +908,10 @@ func TestService_handlePostTaskLabel(t *testing.T) { } } -func TestTaskHandler_CreateTaskFromSession(t *testing.T) { +func TestTaskHandler_Sessions(t *testing.T) { + // Common setup to get a working base for using tasks. i := inmem.NewService() - taskID := platform.ID(9) - var createdTasks []platform.TaskCreate - ts := &mock.TaskService{ - CreateTaskFn: func(_ context.Context, tc platform.TaskCreate) (*platform.Task, error) { - createdTasks = append(createdTasks, tc) - // Task with fake IDs so it can be serialized. - return &platform.Task{ID: taskID, OrganizationID: 99, AuthorizationID: 999, Name: "x"}, nil - }, - // Needed due to task authorization bootstrapping. - UpdateTaskFn: func(ctx context.Context, id platform.ID, tu platform.TaskUpdate) (*platform.Task, error) { - authz, err := i.FindAuthorizationByToken(ctx, tu.Token) - if err != nil { - t.Fatal(err) - } - - return &platform.Task{ID: taskID, OrganizationID: 99, AuthorizationID: authz.ID, Name: "x"}, nil - }, - } - - h := NewTaskHandler(&TaskBackend{ - Logger: zaptest.NewLogger(t), - - TaskService: ts, - AuthorizationService: i, - OrganizationService: i, - UserResourceMappingService: i, - LabelService: i, - UserService: i, - BucketService: i, - }) - ctx := context.Background() // Set up user and org. @@ -965,95 +944,528 @@ func TestTaskHandler_CreateTaskFromSession(t *testing.T) { t.Fatal(err) } - // Create a session for use in authorizing context. - s := &platform.Session{ + sessionAllPermsCtx := pcontext.SetAuthorizer(context.Background(), &platform.Session{ UserID: u.ID, Permissions: platform.OperPermissions(), ExpiresAt: time.Now().Add(24 * time.Hour), - } - - b, err := json.Marshal(platform.TaskCreate{ - Flux: `option task = {name:"x", every:1m} from(bucket:"b-src") |> range(start:-1m) |> to(bucket:"b-dst", org:"o")`, - OrganizationID: o.ID, }) - if err != nil { - t.Fatal(err) + sessionNoPermsCtx := pcontext.SetAuthorizer(context.Background(), &platform.Session{ + UserID: u.ID, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + + newHandler := func(t *testing.T, ts *mock.TaskService) *TaskHandler { + return NewTaskHandler(&TaskBackend{ + Logger: zaptest.NewLogger(t), + + TaskService: ts, + AuthorizationService: i, + OrganizationService: i, + UserResourceMappingService: i, + LabelService: i, + UserService: i, + BucketService: i, + }) } - sessionCtx := pcontext.SetAuthorizer(context.Background(), s) - url := fmt.Sprintf("http://localhost:9999/api/v2/tasks") - r := httptest.NewRequest("POST", url, bytes.NewReader(b)).WithContext(sessionCtx) + t.Run("creating a task from a session", func(t *testing.T) { + taskID := platform.ID(9) + var createdTasks []platform.TaskCreate + ts := &mock.TaskService{ + CreateTaskFn: func(_ context.Context, tc platform.TaskCreate) (*platform.Task, error) { + createdTasks = append(createdTasks, tc) + // Task with fake IDs so it can be serialized. + return &platform.Task{ID: taskID, OrganizationID: 99, AuthorizationID: 999, Name: "x"}, nil + }, + // Needed due to task authorization bootstrapping. + UpdateTaskFn: func(ctx context.Context, id platform.ID, tu platform.TaskUpdate) (*platform.Task, error) { + authz, err := i.FindAuthorizationByToken(ctx, tu.Token) + if err != nil { + t.Fatal(err) + } - w := httptest.NewRecorder() + return &platform.Task{ID: taskID, OrganizationID: 99, AuthorizationID: authz.ID, Name: "x"}, nil + }, + } - h.handlePostTask(w, r) + h := newHandler(t, ts) + url := "http://localhost:9999/api/v2/tasks" - res := w.Result() - body, err := ioutil.ReadAll(res.Body) - if err != nil { - t.Fatal(err) - } - if res.StatusCode != http.StatusCreated { - t.Logf("response body: %s", body) - t.Fatalf("expected status created, got %v", res.StatusCode) - } + b, err := json.Marshal(platform.TaskCreate{ + Flux: `option task = {name:"x", every:1m} from(bucket:"b-src") |> range(start:-1m) |> to(bucket:"b-dst", org:"o")`, + OrganizationID: o.ID, + }) + if err != nil { + t.Fatal(err) + } - if len(createdTasks) != 1 { - t.Fatalf("didn't create task; got %#v", createdTasks) - } + r := httptest.NewRequest("POST", url, bytes.NewReader(b)).WithContext(sessionAllPermsCtx) + w := httptest.NewRecorder() - // The task should have been created with a valid token. - var createdTask platform.Task - if err := json.Unmarshal([]byte(body), &createdTask); err != nil { - t.Fatal(err) - } - authz, err := i.FindAuthorizationByID(ctx, createdTask.AuthorizationID) - if err != nil { - t.Fatal(err) - } - if authz.UserID != u.ID { - t.Fatalf("expected authorization to be associated with user %v, got %v", u.ID, authz.UserID) - } - if authz.OrgID != o.ID { - t.Fatalf("expected authorization to be associated with org %v, got %v", o.ID, authz.OrgID) - } - const expDesc = `auto-generated authorization for task "x"` - if authz.Description != expDesc { - t.Fatalf("expected authorization to be created with description %q, got %q", expDesc, authz.Description) - } + h.handlePostTask(w, r) - // The authorization should be allowed to read and write the target buckets, - // and it should be allowed to read its task. - if !authz.Allowed(platform.Permission{ - Action: platform.ReadAction, - Resource: platform.Resource{ - Type: platform.BucketsResourceType, - OrgID: &o.ID, - ID: &bSrc.ID, - }, - }) { - t.Logf("WARNING: permissions on `from` buckets not yet accessible: update test after https://github.com/influxdata/flux/issues/114 is fixed.") - } + res := w.Result() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusCreated { + t.Logf("response body: %s", body) + t.Fatalf("expected status created, got %v", res.StatusCode) + } - if !authz.Allowed(platform.Permission{ - Action: platform.WriteAction, - Resource: platform.Resource{ - Type: platform.BucketsResourceType, - OrgID: &o.ID, - ID: &bDst.ID, - }, - }) { - t.Fatalf("expected authorization to be allowed write access to destination bucket, but it wasn't allowed") - } + if len(createdTasks) != 1 { + t.Fatalf("didn't create task; got %#v", createdTasks) + } - if !authz.Allowed(platform.Permission{ - Action: platform.ReadAction, - Resource: platform.Resource{ - Type: platform.TasksResourceType, - OrgID: &o.ID, - ID: &taskID, - }, - }) { - t.Fatalf("expected authorization to be allowed to read its task, but it wasn't allowed") - } + // The task should have been created with a valid token. + var createdTask platform.Task + if err := json.Unmarshal([]byte(body), &createdTask); err != nil { + t.Fatal(err) + } + authz, err := i.FindAuthorizationByID(ctx, createdTask.AuthorizationID) + if err != nil { + t.Fatal(err) + } + + if authz.OrgID != o.ID { + t.Fatalf("expected authorization to have org ID %v, got %v", o.ID, authz.OrgID) + } + if authz.UserID != u.ID { + t.Fatalf("expected authorization to have user ID %v, got %v", u.ID, authz.UserID) + } + + const expDesc = `auto-generated authorization for task "x"` + if authz.Description != expDesc { + t.Fatalf("expected authorization to be created with description %q, got %q", expDesc, authz.Description) + } + + // The authorization should be allowed to read and write the target buckets, + // and it should be allowed to read its task. + if !authz.Allowed(platform.Permission{ + Action: platform.ReadAction, + Resource: platform.Resource{ + Type: platform.BucketsResourceType, + OrgID: &o.ID, + ID: &bSrc.ID, + }, + }) { + t.Logf("WARNING: permissions on `from` buckets not yet accessible: update test after https://github.com/influxdata/flux/issues/114 is fixed.") + } + + if !authz.Allowed(platform.Permission{ + Action: platform.WriteAction, + Resource: platform.Resource{ + Type: platform.BucketsResourceType, + OrgID: &o.ID, + ID: &bDst.ID, + }, + }) { + t.Fatalf("expected authorization to be allowed write access to destination bucket, but it wasn't allowed") + } + + if !authz.Allowed(platform.Permission{ + Action: platform.ReadAction, + Resource: platform.Resource{ + Type: platform.TasksResourceType, + OrgID: &o.ID, + ID: &taskID, + }, + }) { + t.Fatalf("expected authorization to be allowed to read its task, but it wasn't allowed") + } + + // Session without permissions should not be allowed to create task. + r = httptest.NewRequest("POST", url, bytes.NewReader(b)).WithContext(sessionNoPermsCtx) + w = httptest.NewRecorder() + + h.handlePostTask(w, r) + + res = w.Result() + body, err = ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized && res.StatusCode != http.StatusForbidden { + t.Logf("response body: %s", body) + t.Fatalf("expected status unauthorized or forbidden, got %v", res.StatusCode) + } + }) + + t.Run("get runs for a task", func(t *testing.T) { + // Unique authorization to associate with our fake task. + taskAuth := &platform.Authorization{OrgID: o.ID, UserID: u.ID} + if err := i.CreateAuthorization(ctx, taskAuth); err != nil { + t.Fatal(err) + } + + const taskID = platform.ID(12345) + const runID = platform.ID(9876) + + var findRunsCtx context.Context + ts := &mock.TaskService{ + FindRunsFn: func(ctx context.Context, f platform.RunFilter) ([]*platform.Run, int, error) { + findRunsCtx = ctx + if f.Task != taskID { + t.Fatalf("expected task ID %v, got %v", taskID, f.Task) + } + + return []*platform.Run{ + {ID: runID, TaskID: taskID}, + }, 1, nil + }, + + FindTaskByIDFn: func(ctx context.Context, id platform.ID) (*platform.Task, error) { + if id != taskID { + return nil, backend.ErrTaskNotFound + } + + return &platform.Task{ + ID: taskID, + OrganizationID: o.ID, + AuthorizationID: taskAuth.ID, + }, nil + }, + } + + h := newHandler(t, ts) + url := fmt.Sprintf("http://localhost:9999/api/v2/tasks/%s/runs", taskID) + valCtx := context.WithValue(sessionAllPermsCtx, httprouter.ParamsKey, httprouter.Params{{Key: "id", Value: taskID.String()}}) + r := httptest.NewRequest("GET", url, nil).WithContext(valCtx) + w := httptest.NewRecorder() + h.handleGetRuns(w, r) + + res := w.Result() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Logf("response body: %s", body) + t.Fatalf("expected status OK, got %v", res.StatusCode) + } + + // The context passed to TaskService.FindRuns must be a valid authorization (not a session). + authr, err := pcontext.GetAuthorizer(findRunsCtx) + if err != nil { + t.Fatal(err) + } + if authr.Kind() != platform.AuthorizationKind { + t.Fatalf("expected context's authorizer to be of kind %q, got %q", platform.AuthorizationKind, authr.Kind()) + } + if authr.Identifier() != taskAuth.ID { + t.Fatalf("expected context's authorizer ID to be %v, got %v", taskAuth.ID, authr.Identifier()) + } + + // Other user without permissions on the task or authorization should be disallowed. + otherUser := &platform.User{Name: "other-" + t.Name()} + if err := i.CreateUser(ctx, otherUser); err != nil { + t.Fatal(err) + } + + valCtx = pcontext.SetAuthorizer(valCtx, &platform.Session{ + UserID: otherUser.ID, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + + r = httptest.NewRequest("GET", url, nil).WithContext(valCtx) + w = httptest.NewRecorder() + h.handleGetRuns(w, r) + + res = w.Result() + body, err = ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Logf("response body: %s", body) + t.Fatalf("expected status unauthorized, got %v", res.StatusCode) + } + }) + + t.Run("get single run for a task", func(t *testing.T) { + // Unique authorization to associate with our fake task. + taskAuth := &platform.Authorization{OrgID: o.ID, UserID: u.ID} + if err := i.CreateAuthorization(ctx, taskAuth); err != nil { + t.Fatal(err) + } + + const taskID = platform.ID(12345) + const runID = platform.ID(9876) + + var findRunByIDCtx context.Context + ts := &mock.TaskService{ + FindRunByIDFn: func(ctx context.Context, tid, rid platform.ID) (*platform.Run, error) { + findRunByIDCtx = ctx + if tid != taskID { + t.Fatalf("expected task ID %v, got %v", taskID, tid) + } + if rid != runID { + t.Fatalf("expected run ID %v, got %v", runID, rid) + } + + return &platform.Run{ID: runID, TaskID: taskID}, nil + }, + + FindTaskByIDFn: func(ctx context.Context, id platform.ID) (*platform.Task, error) { + if id != taskID { + return nil, backend.ErrTaskNotFound + } + + return &platform.Task{ + ID: taskID, + OrganizationID: o.ID, + AuthorizationID: taskAuth.ID, + }, nil + }, + } + + h := newHandler(t, ts) + url := fmt.Sprintf("http://localhost:9999/api/v2/tasks/%s/runs/%s", taskID, runID) + valCtx := context.WithValue(sessionAllPermsCtx, httprouter.ParamsKey, httprouter.Params{ + {Key: "id", Value: taskID.String()}, + {Key: "rid", Value: runID.String()}, + }) + r := httptest.NewRequest("GET", url, nil).WithContext(valCtx) + w := httptest.NewRecorder() + h.handleGetRun(w, r) + + res := w.Result() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Logf("response body: %s", body) + t.Fatalf("expected status OK, got %v", res.StatusCode) + } + + // The context passed to TaskService.FindRunByID must be a valid authorization (not a session). + authr, err := pcontext.GetAuthorizer(findRunByIDCtx) + if err != nil { + t.Fatal(err) + } + if authr.Kind() != platform.AuthorizationKind { + t.Fatalf("expected context's authorizer to be of kind %q, got %q", platform.AuthorizationKind, authr.Kind()) + } + if authr.Identifier() != taskAuth.ID { + t.Fatalf("expected context's authorizer ID to be %v, got %v", taskAuth.ID, authr.Identifier()) + } + + // Other user without permissions on the task or authorization should be disallowed. + otherUser := &platform.User{Name: "other-" + t.Name()} + if err := i.CreateUser(ctx, otherUser); err != nil { + t.Fatal(err) + } + + valCtx = pcontext.SetAuthorizer(valCtx, &platform.Session{ + UserID: otherUser.ID, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + + r = httptest.NewRequest("GET", url, nil).WithContext(valCtx) + w = httptest.NewRecorder() + h.handleGetRuns(w, r) + + res = w.Result() + body, err = ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Logf("response body: %s", body) + t.Fatalf("expected status unauthorized, got %v", res.StatusCode) + } + }) + + t.Run("get logs for a run", func(t *testing.T) { + // Unique authorization to associate with our fake task. + taskAuth := &platform.Authorization{OrgID: o.ID, UserID: u.ID} + if err := i.CreateAuthorization(ctx, taskAuth); err != nil { + t.Fatal(err) + } + + const taskID = platform.ID(12345) + const runID = platform.ID(9876) + + var findLogsCtx context.Context + ts := &mock.TaskService{ + FindLogsFn: func(ctx context.Context, f platform.LogFilter) ([]*platform.Log, int, error) { + findLogsCtx = ctx + if f.Task != taskID { + t.Fatalf("expected task ID %v, got %v", taskID, f.Task) + } + if *f.Run != runID { + t.Fatalf("expected run ID %v, got %v", runID, *f.Run) + } + + line := platform.Log("a log line") + return []*platform.Log{&line}, 1, nil + }, + + FindTaskByIDFn: func(ctx context.Context, id platform.ID) (*platform.Task, error) { + if id != taskID { + return nil, backend.ErrTaskNotFound + } + + return &platform.Task{ + ID: taskID, + OrganizationID: o.ID, + AuthorizationID: taskAuth.ID, + }, nil + }, + } + + h := newHandler(t, ts) + url := fmt.Sprintf("http://localhost:9999/api/v2/tasks/%s/runs/%s/logs", taskID, runID) + valCtx := context.WithValue(sessionAllPermsCtx, httprouter.ParamsKey, httprouter.Params{ + {Key: "id", Value: taskID.String()}, + {Key: "rid", Value: runID.String()}, + }) + r := httptest.NewRequest("GET", url, nil).WithContext(valCtx) + w := httptest.NewRecorder() + h.handleGetLogs(w, r) + + res := w.Result() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Logf("response body: %s", body) + t.Fatalf("expected status OK, got %v", res.StatusCode) + } + + // The context passed to TaskService.FindLogs must be a valid authorization (not a session). + authr, err := pcontext.GetAuthorizer(findLogsCtx) + if err != nil { + t.Fatal(err) + } + if authr.Kind() != platform.AuthorizationKind { + t.Fatalf("expected context's authorizer to be of kind %q, got %q", platform.AuthorizationKind, authr.Kind()) + } + if authr.Identifier() != taskAuth.ID { + t.Fatalf("expected context's authorizer ID to be %v, got %v", taskAuth.ID, authr.Identifier()) + } + + // Other user without permissions on the task or authorization should be disallowed. + otherUser := &platform.User{Name: "other-" + t.Name()} + if err := i.CreateUser(ctx, otherUser); err != nil { + t.Fatal(err) + } + + valCtx = pcontext.SetAuthorizer(valCtx, &platform.Session{ + UserID: otherUser.ID, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + + r = httptest.NewRequest("GET", url, nil).WithContext(valCtx) + w = httptest.NewRecorder() + h.handleGetRuns(w, r) + + res = w.Result() + body, err = ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Logf("response body: %s", body) + t.Fatalf("expected status unauthorized, got %v", res.StatusCode) + } + }) + + t.Run("retry a run", func(t *testing.T) { + // Unique authorization to associate with our fake task. + taskAuth := &platform.Authorization{OrgID: o.ID, UserID: u.ID} + if err := i.CreateAuthorization(ctx, taskAuth); err != nil { + t.Fatal(err) + } + + const taskID = platform.ID(12345) + const runID = platform.ID(9876) + + var retryRunCtx context.Context + ts := &mock.TaskService{ + RetryRunFn: func(ctx context.Context, tid, rid platform.ID) (*platform.Run, error) { + retryRunCtx = ctx + if tid != taskID { + t.Fatalf("expected task ID %v, got %v", taskID, tid) + } + if rid != runID { + t.Fatalf("expected run ID %v, got %v", runID, rid) + } + + return &platform.Run{ID: 10 * runID, TaskID: taskID}, nil + }, + + FindTaskByIDFn: func(ctx context.Context, id platform.ID) (*platform.Task, error) { + if id != taskID { + return nil, backend.ErrTaskNotFound + } + + return &platform.Task{ + ID: taskID, + OrganizationID: o.ID, + AuthorizationID: taskAuth.ID, + }, nil + }, + } + + h := newHandler(t, ts) + url := fmt.Sprintf("http://localhost:9999/api/v2/tasks/%s/runs/%s/retry", taskID, runID) + valCtx := context.WithValue(sessionAllPermsCtx, httprouter.ParamsKey, httprouter.Params{ + {Key: "id", Value: taskID.String()}, + {Key: "rid", Value: runID.String()}, + }) + r := httptest.NewRequest("POST", url, nil).WithContext(valCtx) + w := httptest.NewRecorder() + h.handleRetryRun(w, r) + + res := w.Result() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Logf("response body: %s", body) + t.Fatalf("expected status OK, got %v", res.StatusCode) + } + + // The context passed to TaskService.RetryRun must be a valid authorization (not a session). + authr, err := pcontext.GetAuthorizer(retryRunCtx) + if err != nil { + t.Fatal(err) + } + if authr.Kind() != platform.AuthorizationKind { + t.Fatalf("expected context's authorizer to be of kind %q, got %q", platform.AuthorizationKind, authr.Kind()) + } + if authr.Identifier() != taskAuth.ID { + t.Fatalf("expected context's authorizer ID to be %v, got %v", taskAuth.ID, authr.Identifier()) + } + + // Other user without permissions on the task or authorization should be disallowed. + otherUser := &platform.User{Name: "other-" + t.Name()} + if err := i.CreateUser(ctx, otherUser); err != nil { + t.Fatal(err) + } + + valCtx = pcontext.SetAuthorizer(valCtx, &platform.Session{ + UserID: otherUser.ID, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + + r = httptest.NewRequest("POST", url, nil).WithContext(valCtx) + w = httptest.NewRecorder() + h.handleGetRuns(w, r) + + res = w.Result() + body, err = ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Logf("response body: %s", body) + t.Fatalf("expected status unauthorized, got %v", res.StatusCode) + } + }) } From 3ed16ebe830e833966ba8e3a4cd240c9b5f0d28f Mon Sep 17 00:00:00 2001 From: Alirie Gray Date: Wed, 20 Feb 2019 13:07:04 -0800 Subject: [PATCH 31/54] Add menu for Variables to Time Machine script editor --- ui/src/timeMachine/components/SearchBar.scss | 8 +++ .../SearchBar.tsx | 8 ++- .../components/TimeMachineFluxEditor.scss | 12 +++- .../components/TimeMachineFluxEditor.tsx | 57 +++++++++++++++- ui/src/timeMachine/components/ToolbarTab.scss | 31 +++++++++ ui/src/timeMachine/components/ToolbarTab.tsx | 26 ++++++++ .../FluxFunctionsToolbar.scss | 8 --- .../FluxFunctionsToolbar.tsx | 10 +-- .../FunctionCategory.tsx | 2 +- .../FunctionTooltip.tsx | 8 +-- .../ToolbarFunction.tsx | 2 +- .../TooltipArguments.tsx | 0 .../TooltipDescription.tsx | 0 .../TooltipExample.tsx | 0 .../TooltipLink.tsx | 0 .../TransformToolbarFunctions.tsx | 0 .../variableToolbar/FetchVariables.tsx | 48 ++++++++++++++ .../variableToolbar/VariableItem.tsx | 25 +++++++ .../variableToolbar/VariableToolbar.scss | 42 ++++++++++++ .../variableToolbar/VariableToolbar.tsx | 65 +++++++++++++++++++ 20 files changed, 327 insertions(+), 25 deletions(-) create mode 100644 ui/src/timeMachine/components/SearchBar.scss rename ui/src/timeMachine/components/{flux_functions_toolbar => }/SearchBar.tsx (85%) create mode 100644 ui/src/timeMachine/components/ToolbarTab.scss create mode 100644 ui/src/timeMachine/components/ToolbarTab.tsx rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/FluxFunctionsToolbar.scss (93%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/FluxFunctionsToolbar.tsx (86%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/FunctionCategory.tsx (87%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/FunctionTooltip.tsx (87%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/ToolbarFunction.tsx (95%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/TooltipArguments.tsx (100%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/TooltipDescription.tsx (100%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/TooltipExample.tsx (100%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/TooltipLink.tsx (100%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/TransformToolbarFunctions.tsx (100%) create mode 100644 ui/src/timeMachine/components/variableToolbar/FetchVariables.tsx create mode 100644 ui/src/timeMachine/components/variableToolbar/VariableItem.tsx create mode 100644 ui/src/timeMachine/components/variableToolbar/VariableToolbar.scss create mode 100644 ui/src/timeMachine/components/variableToolbar/VariableToolbar.tsx diff --git a/ui/src/timeMachine/components/SearchBar.scss b/ui/src/timeMachine/components/SearchBar.scss new file mode 100644 index 0000000000..bf99fa2962 --- /dev/null +++ b/ui/src/timeMachine/components/SearchBar.scss @@ -0,0 +1,8 @@ +@import "src/style/modules"; + +.search-bar { + padding: $ix-marg-b; + flex-shrink: 0; + border-bottom: $ix-border solid $g4-onyx; + background-color: $g3-castle; + } \ No newline at end of file diff --git a/ui/src/timeMachine/components/flux_functions_toolbar/SearchBar.tsx b/ui/src/timeMachine/components/SearchBar.tsx similarity index 85% rename from ui/src/timeMachine/components/flux_functions_toolbar/SearchBar.tsx rename to ui/src/timeMachine/components/SearchBar.tsx index 9989a78e04..f7b8accbc0 100644 --- a/ui/src/timeMachine/components/flux_functions_toolbar/SearchBar.tsx +++ b/ui/src/timeMachine/components/SearchBar.tsx @@ -8,8 +8,12 @@ import {Input, IconFont} from 'src/clockface' // Types import {InputType} from 'src/clockface/components/inputs/Input' +// Styles +import 'src/timeMachine/components/SearchBar.scss' + interface Props { onSearch: (s: string) => void + resourceName: string } interface State { @@ -31,11 +35,11 @@ class SearchBar extends PureComponent { public render() { return ( -
+
diff --git a/ui/src/timeMachine/components/TimeMachineFluxEditor.scss b/ui/src/timeMachine/components/TimeMachineFluxEditor.scss index b1eabb0e04..4704b9b548 100644 --- a/ui/src/timeMachine/components/TimeMachineFluxEditor.scss +++ b/ui/src/timeMachine/components/TimeMachineFluxEditor.scss @@ -1,4 +1,14 @@ -@import "src/style/modules"; +@import 'src/style/modules'; + +.toolbar-tab-container { + width: 100%; + display: inline-flex; + align-items: stretch; + height: 38px; + background-color: $g2-kevlar; + padding: $ix-marg-b; + padding-bottom: 0; +} .time-machine-flux-editor { position: absolute; diff --git a/ui/src/timeMachine/components/TimeMachineFluxEditor.tsx b/ui/src/timeMachine/components/TimeMachineFluxEditor.tsx index b97175a59e..924cd66235 100644 --- a/ui/src/timeMachine/components/TimeMachineFluxEditor.tsx +++ b/ui/src/timeMachine/components/TimeMachineFluxEditor.tsx @@ -5,7 +5,9 @@ import {connect} from 'react-redux' // Components import FluxEditor from 'src/shared/components/FluxEditor' import Threesizer from 'src/shared/components/threesizer/Threesizer' -import FluxFunctionsToolbar from 'src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar' +import FluxFunctionsToolbar from 'src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar' +import VariablesToolbar from 'src/timeMachine/components/variableToolbar/VariableToolbar' +import ToolbarTab from 'src/timeMachine/components/ToolbarTab' // Actions import {setActiveQueryText, submitScript} from 'src/timeMachine/actions' @@ -19,6 +21,7 @@ import {HANDLE_VERTICAL, HANDLE_NONE} from 'src/shared/constants' // Types import {AppState} from 'src/types/v2' +// Styles import 'src/timeMachine/components/TimeMachineFluxEditor.scss' interface StateProps { @@ -30,9 +33,21 @@ interface DispatchProps { onSubmitScript: typeof submitScript } +interface State { + displayFluxFunctions: boolean +} + type Props = StateProps & DispatchProps -class TimeMachineFluxEditor extends PureComponent { +class TimeMachineFluxEditor extends PureComponent { + constructor(props) { + super(props) + + this.state = { + displayFluxFunctions: false, + } + } + public render() { const {activeQueryText, onSetActiveQueryText, onSubmitScript} = this.props @@ -51,7 +66,25 @@ class TimeMachineFluxEditor extends PureComponent { ), }, { - render: () => , + render: () => { + return ( + <> +
+ + +
+ {this.rightDivision} + + ) + }, handlePixels: 6, size: 0.25, }, @@ -63,6 +96,24 @@ class TimeMachineFluxEditor extends PureComponent {
) } + + private get rightDivision(): JSX.Element { + const {displayFluxFunctions} = this.state + + if (displayFluxFunctions) { + return + } + + return + } + + private showFluxFunctions = () => { + this.setState({displayFluxFunctions: true}) + } + + private hideFluxFunctions = () => { + this.setState({displayFluxFunctions: false}) + } } const mstp = (state: AppState) => { diff --git a/ui/src/timeMachine/components/ToolbarTab.scss b/ui/src/timeMachine/components/ToolbarTab.scss new file mode 100644 index 0000000000..9f379db3f2 --- /dev/null +++ b/ui/src/timeMachine/components/ToolbarTab.scss @@ -0,0 +1,31 @@ +@import 'src/style/modules'; + +.toolbar-tab { + background: rgba($g3-castle, 0.5); + margin-right: $ix-border; + border-radius: $ix-radius $ix-radius 0 0; + display: flex; + align-items: center; + color: $g10-wolf; + font-weight: 700; + padding: 0 ($ix-marg-c - $ix-marg-a); + font-size: $ix-text-tiny; + user-select: none; + white-space: nowrap; + transition: color 0.25s ease, background-color 0.25s ease; + + &:last-child { + margin-right: 0; + } + + &.active { + flex: 0 0 auto; + background: $g3-castle; + color: $g16-pearl; + } + + &:hover { + color: $g16-pearl; + cursor: pointer; + } +} diff --git a/ui/src/timeMachine/components/ToolbarTab.tsx b/ui/src/timeMachine/components/ToolbarTab.tsx new file mode 100644 index 0000000000..9be445864c --- /dev/null +++ b/ui/src/timeMachine/components/ToolbarTab.tsx @@ -0,0 +1,26 @@ +// Libraries +import React, {PureComponent} from 'react' + +// Styles +import 'src/timeMachine/components/ToolbarTab.scss' + +interface Props { + onSetActive: () => void + name: string + active: boolean +} + +export default class ToolbarTab extends PureComponent { + public render() { + const {active, onSetActive, name} = this.props + return ( +
+ {name} +
+ ) + } +} diff --git a/ui/src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.scss b/ui/src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.scss similarity index 93% rename from ui/src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.scss rename to ui/src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.scss index 4a2f2c1772..e825580f08 100644 --- a/ui/src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.scss +++ b/ui/src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.scss @@ -8,14 +8,6 @@ font-size: 13px; } -.flux-functions-toolbar--search { - padding: $ix-marg-a; - flex-shrink: 0; - padding-bottom: $ix-marg-a + 1px; - border-bottom: $ix-border solid $g4-onyx; - background-color: $g3-castle; -} - .flux-functions-toolbar--list { padding-bottom: $ix-marg-a; } diff --git a/ui/src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.tsx b/ui/src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.tsx similarity index 86% rename from ui/src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.tsx rename to ui/src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.tsx index dc50935331..f32dcaa3c2 100644 --- a/ui/src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.tsx +++ b/ui/src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.tsx @@ -3,9 +3,9 @@ import React, {PureComponent} from 'react' import {connect} from 'react-redux' // Components -import TransformToolbarFunctions from 'src/timeMachine/components/flux_functions_toolbar/TransformToolbarFunctions' -import FunctionCategory from 'src/timeMachine/components/flux_functions_toolbar/FunctionCategory' -import SearchBar from 'src/timeMachine/components/flux_functions_toolbar/SearchBar' +import TransformToolbarFunctions from 'src/timeMachine/components/fluxFunctionsToolbar/TransformToolbarFunctions' +import FunctionCategory from 'src/timeMachine/components/fluxFunctionsToolbar/FunctionCategory' +import SearchBar from 'src/timeMachine/components/SearchBar' import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar' import {ErrorHandling} from 'src/shared/decorators/errors' @@ -19,7 +19,7 @@ import {getActiveQuery} from 'src/timeMachine/selectors' import {FLUX_FUNCTIONS} from 'src/shared/constants/fluxFunctions' // Styles -import 'src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.scss' +import 'src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.scss' // Types import {AppState} from 'src/types/v2' @@ -46,7 +46,7 @@ class FluxFunctionsToolbar extends PureComponent { return (
- +
JSX.Element +} + +interface State { + variables: Variable[] + loading: RemoteDataState +} + +class FetchVariables extends PureComponent { + public state: State = { + variables: [], + loading: RemoteDataState.NotStarted, + } + + public async componentDidMount() { + this.fetchVariables() + } + + public render() { + const {variables, loading} = this.state + + return this.props.children(variables, loading) + } + + public fetchVariables = async () => { + this.setState({loading: RemoteDataState.Loading}) + + const variables = await client.variables.getAll() + this.setState({ + variables: _.sortBy(variables, ['name']), + loading: RemoteDataState.Done, + }) + } +} + +export default FetchVariables diff --git a/ui/src/timeMachine/components/variableToolbar/VariableItem.tsx b/ui/src/timeMachine/components/variableToolbar/VariableItem.tsx new file mode 100644 index 0000000000..cd97cb49a4 --- /dev/null +++ b/ui/src/timeMachine/components/variableToolbar/VariableItem.tsx @@ -0,0 +1,25 @@ +// Libraries +import React, {PureComponent} from 'react' + +// Types +import {Variable} from '@influxdata/influx' + +// Styles +import 'src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.scss' + +interface Props { + variable: Variable +} + +class VariableItem extends PureComponent { + public render() { + const {variable} = this.props + return ( +
+
{variable.name}
+
+ ) + } +} + +export default VariableItem diff --git a/ui/src/timeMachine/components/variableToolbar/VariableToolbar.scss b/ui/src/timeMachine/components/variableToolbar/VariableToolbar.scss new file mode 100644 index 0000000000..6cf3b60d84 --- /dev/null +++ b/ui/src/timeMachine/components/variableToolbar/VariableToolbar.scss @@ -0,0 +1,42 @@ +@import 'src/style/modules'; + +.variable-toolbar { + height: 100%; + display: flex; + flex-direction: column; + background-color: $g3-castle; + font-size: 13px; +} + +.variables-toolbar--list { + padding-bottom: $ix-marg-a; +} + +.variables-toolbar--item { + position: relative; +} + +.variables-toolbar--label, +.variables-toolbar--separator { + height: 30px; + display: flex; + align-items: center; + padding-left: $ix-marg-b; +} + +.variables-toolbar--label { + font-family: 'RobotoMono', monospace; + cursor: pointer; + + &:hover, + &:active { + background-color: $g4-onyx; + color: $c-laser; + } +} + +.variables-toolbar--separator { + background-color: $g6-smoke; + font-weight: 600; + color: $g18-cloud; +} diff --git a/ui/src/timeMachine/components/variableToolbar/VariableToolbar.tsx b/ui/src/timeMachine/components/variableToolbar/VariableToolbar.tsx new file mode 100644 index 0000000000..f3ef903a54 --- /dev/null +++ b/ui/src/timeMachine/components/variableToolbar/VariableToolbar.tsx @@ -0,0 +1,65 @@ +// Libraries +import React, {PureComponent} from 'react' + +// Components +import SearchBar from 'src/timeMachine/components/SearchBar' +import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar' +import VariableItem from 'src/timeMachine/components/variableToolbar/VariableItem' + +// Styles +import 'src/timeMachine/components/variableToolbar/VariableToolbar.scss' +import {SpinnerContainer, TechnoSpinner} from '@influxdata/clockface' +import FetchVariables from 'src/timeMachine/components/variableToolbar/FetchVariables' + +interface State { + searchTerm: string +} + +class VariableToolbar extends PureComponent<{}, State> { + constructor(props) { + super(props) + + this.state = { + searchTerm: '', + } + } + + public render() { + return ( + + {(variables, loading) => { + return ( + } + > +
+ + +
+ {variables + .filter(v => { + return v.name.includes(this.state.searchTerm) + }) + .map(v => { + return + })} +
+
+
+
+ ) + }} +
+ ) + } + + private handleSearch = (searchTerm: string): void => { + this.setState({searchTerm}) + } +} + +export default VariableToolbar From e92ec5d3f16dfbf6b6bbcab8b8c4c1a3ae624826 Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Fri, 22 Feb 2019 08:51:38 -0800 Subject: [PATCH 32/54] Rename layer "aesthetics" to "mappings" --- ui/src/minard/components/Histogram.tsx | 12 ++++++------ ui/src/minard/components/HistogramBars.tsx | 10 +++++----- ui/src/minard/index.ts | 2 +- ui/src/minard/utils/getBarFill.ts | 4 ++-- .../minard/utils/getHistogramTooltipProps.ts | 12 ++++++------ ui/src/minard/utils/plotEnvReducer.ts | 19 ++++++++++--------- 6 files changed, 30 insertions(+), 29 deletions(-) diff --git a/ui/src/minard/components/Histogram.tsx b/ui/src/minard/components/Histogram.tsx index 977072f72d..91fcad306a 100644 --- a/ui/src/minard/components/Histogram.tsx +++ b/ui/src/minard/components/Histogram.tsx @@ -47,7 +47,7 @@ export const Histogram: SFC = ({ const layer = useLayer( env, () => { - const [table, aesthetics] = bin( + const [table, mappings] = bin( baseTable, x, xDomain, @@ -56,7 +56,7 @@ export const Histogram: SFC = ({ position ) - return {table, aesthetics, colors, scales: {}} + return {table, mappings, colors, scales: {}} }, [baseTable, xDomain, x, fill, position, binCount, colors] ) @@ -75,12 +75,12 @@ export const Histogram: SFC = ({ }, } = env - const {aesthetics, table} = layer + const {mappings, table} = layer const hoveredRowIndices = findHoveredRowIndices( - table.columns[aesthetics.xMin], - table.columns[aesthetics.xMax], - table.columns[aesthetics.yMax], + table.columns[mappings.xMin], + table.columns[mappings.xMax], + table.columns[mappings.yMax], hoverX, hoverY, xScale, diff --git a/ui/src/minard/components/HistogramBars.tsx b/ui/src/minard/components/HistogramBars.tsx index 8e1938e270..92ff8c4593 100644 --- a/ui/src/minard/components/HistogramBars.tsx +++ b/ui/src/minard/components/HistogramBars.tsx @@ -24,11 +24,11 @@ const drawBars = ( ): void => { clearCanvas(canvas, width, height) - const {table, aesthetics} = layer - const xMinCol = table.columns[aesthetics.xMin] - const xMaxCol = table.columns[aesthetics.xMax] - const yMinCol = table.columns[aesthetics.yMin] - const yMaxCol = table.columns[aesthetics.yMax] + const {table, mappings} = layer + const xMinCol = table.columns[mappings.xMin] + const xMaxCol = table.columns[mappings.xMax] + const yMinCol = table.columns[mappings.yMin] + const yMaxCol = table.columns[mappings.yMax] const context = canvas.getContext('2d') diff --git a/ui/src/minard/index.ts b/ui/src/minard/index.ts index 598c5b052d..a7a42ddcfb 100644 --- a/ui/src/minard/index.ts +++ b/ui/src/minard/index.ts @@ -39,7 +39,7 @@ export interface AestheticScaleMappings { export interface Layer { table?: Table - aesthetics: AestheticDataMappings + mappings: AestheticDataMappings scales: AestheticScaleMappings colors?: string[] xDomain?: [number, number] diff --git a/ui/src/minard/utils/getBarFill.ts b/ui/src/minard/utils/getBarFill.ts index 894401d9bd..d11963b2fd 100644 --- a/ui/src/minard/utils/getBarFill.ts +++ b/ui/src/minard/utils/getBarFill.ts @@ -13,11 +13,11 @@ import {getGroupKey} from 'src/minard/utils/getGroupKey' // key”) that the scale uses as a domain // 3. Lookup the scale and get the color via this representation export const getBarFill = ( - {scales, aesthetics, table}: Layer, + {scales, mappings, table}: Layer, i: number ): string => { const fillScale = scales.fill - const values = aesthetics.fill.map(colKey => table.columns[colKey][i]) + const values = mappings.fill.map(colKey => table.columns[colKey][i]) const groupKey = getGroupKey(values) const fill = fillScale(groupKey) diff --git a/ui/src/minard/utils/getHistogramTooltipProps.ts b/ui/src/minard/utils/getHistogramTooltipProps.ts index a657a68018..1827cd38aa 100644 --- a/ui/src/minard/utils/getHistogramTooltipProps.ts +++ b/ui/src/minard/utils/getHistogramTooltipProps.ts @@ -5,14 +5,14 @@ export const getHistogramTooltipProps = ( layer: Layer, rowIndices: number[] ): HistogramTooltipProps => { - const {table, aesthetics} = layer - const xMinCol = table.columns[aesthetics.xMin] - const xMaxCol = table.columns[aesthetics.xMax] - const yMinCol = table.columns[aesthetics.yMin] - const yMaxCol = table.columns[aesthetics.yMax] + const {table, mappings} = layer + const xMinCol = table.columns[mappings.xMin] + const xMaxCol = table.columns[mappings.xMax] + const yMinCol = table.columns[mappings.yMin] + const yMaxCol = table.columns[mappings.yMax] const counts = rowIndices.map(i => { - const grouping = aesthetics.fill.reduce( + const grouping = mappings.fill.reduce( (acc, colName) => ({ ...acc, [colName]: table.columns[colName][i], diff --git a/ui/src/minard/utils/plotEnvReducer.ts b/ui/src/minard/utils/plotEnvReducer.ts index d0509dcad4..990c932ff9 100644 --- a/ui/src/minard/utils/plotEnvReducer.ts +++ b/ui/src/minard/utils/plotEnvReducer.ts @@ -33,7 +33,7 @@ export const INITIAL_PLOT_ENV: PlotEnv = { yDomain: null, baseLayer: { table: {columns: {}, columnTypes: {}}, - aesthetics: {}, + mappings: {}, scales: {}, }, layers: {}, @@ -117,20 +117,21 @@ export const plotEnvReducer = (state: PlotEnv, action: PlotAction): PlotEnv => /* Find all columns in the current in all layers that are mapped to the supplied - aesthetics + aesthetic mappings */ const getColumnsForAesthetics = ( state: PlotEnv, - aesthetics: string[] + mappings: string[] ): any[][] => { const {baseLayer, layers} = state const cols = [] for (const layer of Object.values(layers)) { - for (const aes of aesthetics) { - if (layer.aesthetics[aes]) { - const colName = layer.aesthetics[aes] + for (const mapping of mappings) { + const colName = layer.mappings[mapping] + + if (colName) { const col = layer.table ? layer.table.columns[colName] : baseLayer.table.columns[colName] @@ -272,8 +273,8 @@ const getColorScale = ( of data (for now). So the domain of the scale is a set of "group keys" which represent all possible groupings of data in the layer. */ -const getFillDomain = ({table, aesthetics}: Layer): string[] => { - const fillColKeys = aesthetics.fill +const getFillDomain = ({table, mappings}: Layer): string[] => { + const fillColKeys = mappings.fill if (!fillColKeys.length) { return [] @@ -299,7 +300,7 @@ const setFillScales = (draftState: PlotEnv) => { layers .filter( // Pick out the layers that actually need a fill scale - layer => layer.aesthetics.fill && layer.colors && layer.colors.length + layer => layer.mappings.fill && layer.colors && layer.colors.length ) .forEach(layer => { layer.scales.fill = getColorScale(getFillDomain(layer), layer.colors) From 5d8b217f662c994316b6f321470ad3b89acb269d Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Fri, 22 Feb 2019 10:54:07 -0800 Subject: [PATCH 33/54] Refactor vis table shape and types --- ui/src/minard/components/Histogram.tsx | 12 +- ui/src/minard/components/HistogramBars.tsx | 14 +- ui/src/minard/components/HistogramTooltip.tsx | 4 +- ui/src/minard/index.ts | 217 +++++++++--------- ui/src/minard/utils/bin.test.ts | 178 +++++++------- ui/src/minard/utils/bin.ts | 73 +++--- ui/src/minard/utils/findHoveredRowIndices.tsx | 9 +- ui/src/minard/utils/getBarFill.ts | 6 +- .../minard/utils/getHistogramTooltipProps.ts | 15 +- ui/src/minard/utils/isNumeric.ts | 6 + ui/src/minard/utils/plotEnvActions.ts | 4 +- ui/src/minard/utils/plotEnvReducer.ts | 37 +-- ui/src/minard/utils/useLayer.ts | 2 +- ui/src/shared/components/Histogram.tsx | 64 +++--- ui/src/shared/utils/toMinardTable.test.ts | 106 +++++---- ui/src/shared/utils/toMinardTable.ts | 56 ++--- ui/src/timeMachine/actions/index.ts | 6 +- 17 files changed, 436 insertions(+), 373 deletions(-) create mode 100644 ui/src/minard/utils/isNumeric.ts diff --git a/ui/src/minard/components/Histogram.tsx b/ui/src/minard/components/Histogram.tsx index 91fcad306a..dd0b0ed048 100644 --- a/ui/src/minard/components/Histogram.tsx +++ b/ui/src/minard/components/Histogram.tsx @@ -1,6 +1,6 @@ import React, {SFC} from 'react' -import {PlotEnv} from 'src/minard' +import {PlotEnv, HistogramLayer} from 'src/minard' import {bin} from 'src/minard/utils/bin' import HistogramBars from 'src/minard/components/HistogramBars' import HistogramTooltip from 'src/minard/components/HistogramTooltip' @@ -56,10 +56,10 @@ export const Histogram: SFC = ({ position ) - return {table, mappings, colors, scales: {}} + return {type: 'histogram', table, mappings, colors} }, [baseTable, xDomain, x, fill, position, binCount, colors] - ) + ) as HistogramLayer if (!layer) { return null @@ -75,12 +75,10 @@ export const Histogram: SFC = ({ }, } = env - const {mappings, table} = layer + const {table} = layer const hoveredRowIndices = findHoveredRowIndices( - table.columns[mappings.xMin], - table.columns[mappings.xMax], - table.columns[mappings.yMax], + table, hoverX, hoverY, xScale, diff --git a/ui/src/minard/components/HistogramBars.tsx b/ui/src/minard/components/HistogramBars.tsx index 92ff8c4593..4e285ebaed 100644 --- a/ui/src/minard/components/HistogramBars.tsx +++ b/ui/src/minard/components/HistogramBars.tsx @@ -1,6 +1,6 @@ import React, {useRef, useLayoutEffect, SFC} from 'react' -import {Scale, HistogramPosition, Layer} from 'src/minard' +import {Scale, HistogramPosition, HistogramLayer} from 'src/minard' import {clearCanvas} from 'src/minard/utils/clearCanvas' import {getBarFill} from 'src/minard/utils/getBarFill' @@ -11,7 +11,7 @@ const BAR_PADDING = 1.5 interface Props { width: number height: number - layer: Layer + layer: HistogramLayer xScale: Scale yScale: Scale position: HistogramPosition @@ -24,11 +24,11 @@ const drawBars = ( ): void => { clearCanvas(canvas, width, height) - const {table, mappings} = layer - const xMinCol = table.columns[mappings.xMin] - const xMaxCol = table.columns[mappings.xMax] - const yMinCol = table.columns[mappings.yMin] - const yMaxCol = table.columns[mappings.yMax] + const {table} = layer + const xMinCol = table.columns.xMin.data + const xMaxCol = table.columns.xMax.data + const yMinCol = table.columns.yMin.data + const yMaxCol = table.columns.yMax.data const context = canvas.getContext('2d') diff --git a/ui/src/minard/components/HistogramTooltip.tsx b/ui/src/minard/components/HistogramTooltip.tsx index 1572a4249b..19e53e4885 100644 --- a/ui/src/minard/components/HistogramTooltip.tsx +++ b/ui/src/minard/components/HistogramTooltip.tsx @@ -1,6 +1,6 @@ import React, {useRef, SFC} from 'react' -import {HistogramTooltipProps, Layer} from 'src/minard' +import {HistogramTooltipProps, HistogramLayer} from 'src/minard' import {useLayoutStyle} from 'src/minard/utils/useLayoutStyle' import {useMousePos} from 'src/minard/utils/useMousePos' import {getHistogramTooltipProps} from 'src/minard/utils/getHistogramTooltipProps' @@ -12,7 +12,7 @@ interface Props { hoverX: number hoverY: number tooltip?: (props: HistogramTooltipProps) => JSX.Element - layer: Layer + layer: HistogramLayer hoveredRowIndices: number[] | null } diff --git a/ui/src/minard/index.ts b/ui/src/minard/index.ts index a7a42ddcfb..cbec000dd0 100644 --- a/ui/src/minard/index.ts +++ b/ui/src/minard/index.ts @@ -17,35 +17,120 @@ export { TooltipProps as HistogramTooltipProps, } from 'src/minard/components/Histogram' +export {isNumeric} from 'src/minard/utils/isNumeric' + +export type ColumnType = 'int' | 'uint' | 'float' | 'string' | 'time' | 'bool' + +export type NumericColumnType = 'int' | 'uint' | 'float' | 'time' + +export interface FloatColumn { + data: number[] + type: 'float' +} + +export interface IntColumn { + data: number[] + type: 'int' +} + +export interface UIntColumn { + data: number[] + type: 'uint' +} + +export interface TimeColumn { + data: number[] + type: 'time' +} + +export interface StringColumn { + data: string[] + type: 'string' +} + +export interface BoolColumn { + data: boolean[] + type: 'bool' +} + +export type NumericTableColumn = + | FloatColumn + | IntColumn + | UIntColumn + | TimeColumn + +export type TableColumn = + | FloatColumn + | IntColumn + | UIntColumn + | TimeColumn + | StringColumn + | BoolColumn + +export interface Table { + length: number + columns: { + [columnName: string]: TableColumn + } +} + +export type LayerType = 'base' | 'histogram' + export interface Scale { (x: D): R invert?: (y: R) => D } -export interface AestheticDataMappings { - x?: string - fill?: string[] - xMin?: string - xMax?: string - yMin?: string - yMax?: string +export interface BaseLayerMappings {} + +export interface BaseLayerScales { + x: Scale + y: Scale } -export interface AestheticScaleMappings { - x?: Scale - y?: Scale - fill?: Scale +export interface BaseLayer { + type: 'base' + table: Table + scales: BaseLayerScales + mappings: {} + xDomain: [number, number] + yDomain: [number, number] } -export interface Layer { - table?: Table - mappings: AestheticDataMappings - scales: AestheticScaleMappings - colors?: string[] - xDomain?: [number, number] - yDomain?: [number, number] +export interface HistogramTable extends Table { + columns: { + xMin: NumericTableColumn + xMax: NumericTableColumn + yMin: IntColumn + yMax: IntColumn + [fillColumn: string]: TableColumn + } + length: number } +export interface HistogramMappings { + xMin: 'xMin' + xMax: 'xMax' + yMin: 'yMin' + yMax: 'yMax' + fill: string[] +} + +export interface HistogramScales { + // x and y scale are from the `BaseLayer` + fill: Scale +} + +export interface HistogramLayer { + type: 'histogram' + table: HistogramTable + mappings: HistogramMappings + scales: HistogramScales + colors: string[] +} + +export type Layer = BaseLayer | HistogramLayer + export interface Margins { top: number right: number @@ -69,103 +154,9 @@ export interface PlotEnv { xDomain: [number, number] yDomain: [number, number] - baseLayer: Layer + baseLayer: BaseLayer layers: {[layerKey: string]: Layer} hoverX: number hoverY: number dispatch: (action: PlotAction) => void } - -export enum ColumnType { - Numeric = 'numeric', - Categorical = 'categorical', - Temporal = 'temporal', - Boolean = 'bool', -} - -export interface Table { - columns: {[columnName: string]: any[]} - columnTypes: {[columnName: string]: ColumnType} -} - -// export enum InterpolationKind { -// Linear = 'linear', -// MonotoneX = 'monotoneX', -// MonotoneY = 'monotoneY', -// Cubic = 'cubic', -// Step = 'step', -// StepBefore = 'stepBefore', -// StepAfter = 'stepAfter', -// } - -// export interface LineProps { -// x?: string -// y?: string -// stroke?: string -// strokeWidth?: string -// interpolate?: InterpolationKind -// } - -// export enum AreaPositionKind { -// Stack = 'stack', -// Overlay = 'overlay', -// } - -// export interface AreaProps { -// x?: string -// y?: string -// position?: AreaPositionKind -// } - -// export enum ShapeKind { -// Point = 'point', -// // Spade, Heart, Club, Triangle, Hexagon, etc. -// } - -// export interface PointProps { -// x?: string -// y?: string -// fill?: string -// shape?: ShapeKind -// radius?: number -// alpha?: number -// } - -// export interface ContinuousBarProps { -// x0?: string -// x1?: string -// y?: string -// fill?: string -// } - -// export enum DiscreteBarPositionKind { -// Stack = 'stack', -// Dodge = 'dodge', -// } - -// export interface DiscreteBarProps { -// x?: string -// y?: string -// fill?: string -// position?: DiscreteBarPositionKind -// } - -// export interface StepLineProps { -// x0?: string -// x1?: string -// y?: string -// } - -// export interface StepAreaProps { -// x0?: string -// x1?: string -// y?: string -// position?: AreaPositionKind -// } - -// export interface Bin2DProps { -// x?: string -// y?: string -// binWidth?: number -// binHeight?: number -// } diff --git a/ui/src/minard/utils/bin.test.ts b/ui/src/minard/utils/bin.test.ts index a319e24537..27665fc63f 100644 --- a/ui/src/minard/utils/bin.test.ts +++ b/ui/src/minard/utils/bin.test.ts @@ -1,39 +1,44 @@ -import {HistogramPosition, ColumnType} from 'src/minard' +import {HistogramPosition, Table} from 'src/minard' import {bin} from 'src/minard/utils/bin' -const TABLE = { +const TABLE: Table = { columns: { - _value: [70, 56, 60, 100, 76, 0, 63, 48, 79, 67], - _field: [ - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_idle', - 'usage_idle', - 'usage_idle', - 'usage_idle', - 'usage_idle', - ], - cpu: [ - 'cpu0', - 'cpu0', - 'cpu0', - 'cpu1', - 'cpu1', - 'cpu0', - 'cpu0', - 'cpu0', - 'cpu1', - 'cpu1', - ], - }, - columnTypes: { - _value: ColumnType.Numeric, - _field: ColumnType.Categorical, - cpu: ColumnType.Categorical, + _value: { + data: [70, 56, 60, 100, 76, 0, 63, 48, 79, 67], + type: 'int', + }, + _field: { + data: [ + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_idle', + 'usage_idle', + 'usage_idle', + 'usage_idle', + 'usage_idle', + ], + type: 'string', + }, + cpu: { + data: [ + 'cpu0', + 'cpu0', + 'cpu0', + 'cpu1', + 'cpu1', + 'cpu0', + 'cpu0', + 'cpu0', + 'cpu1', + 'cpu1', + ], + type: 'string', + }, }, + length: 10, } describe('bin', () => { @@ -41,20 +46,15 @@ describe('bin', () => { const actual = bin(TABLE, '_value', null, [], 5, HistogramPosition.Stacked) const expected = [ { - columnTypes: { - xMax: 'numeric', - xMin: 'numeric', - yMax: 'numeric', - yMin: 'numeric', - }, columns: { - xMax: [20, 40, 60, 80, 100], - xMin: [0, 20, 40, 60, 80], - yMax: [1, 0, 2, 6, 1], - yMin: [0, 0, 0, 0, 0], + xMin: {data: [0, 20, 40, 60, 80], type: 'int'}, + xMax: {data: [20, 40, 60, 80, 100], type: 'int'}, + yMin: {data: [0, 0, 0, 0, 0], type: 'int'}, + yMax: {data: [1, 0, 2, 6, 1], type: 'int'}, }, + length: 5, }, - {fill: [], xMax: 'xMax', xMin: 'xMin', yMax: 'yMax', yMin: 'yMin'}, + {xMin: 'xMin', xMax: 'xMax', yMin: 'yMin', yMax: 'yMax', fill: []}, ] expect(actual).toEqual(expected) @@ -71,22 +71,25 @@ describe('bin', () => { )[0].columns const expected = { - _field: [ - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_idle', - 'usage_idle', - 'usage_idle', - 'usage_idle', - 'usage_idle', - ], - xMax: [20, 40, 60, 80, 100, 20, 40, 60, 80, 100], - xMin: [0, 20, 40, 60, 80, 0, 20, 40, 60, 80], - yMax: [0, 0, 1, 3, 1, 1, 0, 2, 6, 1], - yMin: [0, 0, 0, 0, 0, 0, 0, 1, 3, 1], + xMin: {data: [0, 20, 40, 60, 80, 0, 20, 40, 60, 80], type: 'int'}, + xMax: {data: [20, 40, 60, 80, 100, 20, 40, 60, 80, 100], type: 'int'}, + yMin: {data: [0, 0, 0, 0, 0, 0, 0, 1, 3, 1], type: 'int'}, + yMax: {data: [0, 0, 1, 3, 1, 1, 0, 2, 6, 1], type: 'int'}, + _field: { + data: [ + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_idle', + 'usage_idle', + 'usage_idle', + 'usage_idle', + 'usage_idle', + ], + type: 'string', + }, } expect(actual).toEqual(expected) @@ -103,22 +106,25 @@ describe('bin', () => { )[0].columns const expected = { - _field: [ - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_idle', - 'usage_idle', - 'usage_idle', - 'usage_idle', - 'usage_idle', - ], - xMax: [20, 40, 60, 80, 100, 20, 40, 60, 80, 100], - xMin: [0, 20, 40, 60, 80, 0, 20, 40, 60, 80], - yMax: [0, 0, 1, 3, 1, 1, 0, 1, 3, 0], - yMin: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + xMin: {data: [0, 20, 40, 60, 80, 0, 20, 40, 60, 80], type: 'int'}, + xMax: {data: [20, 40, 60, 80, 100, 20, 40, 60, 80, 100], type: 'int'}, + yMin: {data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], type: 'int'}, + yMax: {data: [0, 0, 1, 3, 1, 1, 0, 1, 3, 0], type: 'int'}, + _field: { + data: [ + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_idle', + 'usage_idle', + 'usage_idle', + 'usage_idle', + 'usage_idle', + ], + type: 'string', + }, } expect(actual).toEqual(expected) @@ -135,10 +141,16 @@ describe('bin', () => { )[0].columns const expected = { - xMax: [-160, -120, -80, -40, 0, 40, 80, 120, 160, 200], - xMin: [-200, -160, -120, -80, -40, 0, 40, 80, 120, 160], - yMax: [0, 0, 0, 0, 0, 1, 8, 1, 0, 0], - yMin: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + xMin: { + data: [-200, -160, -120, -80, -40, 0, 40, 80, 120, 160], + type: 'int', + }, + xMax: { + data: [-160, -120, -80, -40, 0, 40, 80, 120, 160, 200], + type: 'int', + }, + yMin: {data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], type: 'int'}, + yMax: {data: [0, 0, 0, 0, 0, 1, 8, 1, 0, 0], type: 'int'}, } expect(actual).toEqual(expected) @@ -155,10 +167,10 @@ describe('bin', () => { )[0].columns const expected = { - xMax: [60, 70, 80], - xMin: [50, 60, 70], - yMax: [1, 3, 3], - yMin: [0, 0, 0], + xMin: {data: [50, 60, 70], type: 'int'}, + xMax: {data: [60, 70, 80], type: 'int'}, + yMin: {data: [0, 0, 0], type: 'int'}, + yMax: {data: [1, 3, 3], type: 'int'}, } expect(actual).toEqual(expected) diff --git a/ui/src/minard/utils/bin.ts b/ui/src/minard/utils/bin.ts index 243cb9777e..fe31ca3579 100644 --- a/ui/src/minard/utils/bin.ts +++ b/ui/src/minard/utils/bin.ts @@ -1,6 +1,13 @@ import {extent, range, thresholdSturges} from 'd3-array' -import {Table, HistogramPosition, ColumnType} from 'src/minard' +import { + Table, + HistogramTable, + HistogramMappings, + HistogramPosition, + NumericColumnType, + isNumeric, +} from 'src/minard' import {assert} from 'src/minard/utils/assert' import {getGroupKey} from 'src/minard/utils/getGroupKey' @@ -37,15 +44,14 @@ export const bin = ( groupColNames: string[] = [], binCount: number, position: HistogramPosition -) => { - const xCol = table.columns[xColName] - const xColType = table.columnTypes[xColName] +): [HistogramTable, HistogramMappings] => { + const col = table.columns[xColName] - assert(`could not find column "${xColName}"`, !!xCol) - assert( - `unsupported value column type "${xColType}"`, - xColType === ColumnType.Numeric || xColType === ColumnType.Temporal - ) + assert(`could not find column "${xColName}"`, !!col) + assert(`unsupported value column type "${col.type}"`, isNumeric(col.type)) + + const xCol = col.data as number[] + const xColType = col.type as NumericColumnType if (!binCount) { binCount = thresholdSturges(xCol) @@ -91,24 +97,37 @@ export const bin = ( } // Next, build up a tabular representation of each of these bins by group + const groupKeys = Object.keys(groupsByGroupKey) const statTable = { - columns: {xMin: [], xMax: [], yMin: [], yMax: []}, - columnTypes: { - xMin: xColType, - xMax: xColType, - yMin: ColumnType.Numeric, - yMax: ColumnType.Numeric, + columns: { + xMin: { + data: [], + type: xColType, + }, + xMax: { + data: [], + type: xColType, + }, + yMin: { + data: [], + type: 'int', + }, + yMax: { + data: [], + type: 'int', + }, }, + length: binCount * groupKeys.length, } // Include original columns used to group data in the resulting table for (const name of groupColNames) { - statTable.columns[name] = [] - statTable.columnTypes[name] = table.columnTypes[name] + statTable.columns[name] = { + data: [], + type: table.columns[name].type, + } } - const groupKeys = Object.keys(groupsByGroupKey) - for (let i = 0; i < groupKeys.length; i++) { const groupKey = groupKeys[i] @@ -121,18 +140,18 @@ export const bin = ( .reduce((sum, k) => sum + (bin.values[k] || 0), 0) } - statTable.columns.xMin.push(bin.min) - statTable.columns.xMax.push(bin.max) - statTable.columns.yMin.push(yMin) - statTable.columns.yMax.push(yMin + (bin.values[groupKey] || 0)) + statTable.columns.xMin.data.push(bin.min) + statTable.columns.xMax.data.push(bin.max) + statTable.columns.yMin.data.push(yMin) + statTable.columns.yMax.data.push(yMin + (bin.values[groupKey] || 0)) for (const [k, v] of Object.entries(groupsByGroupKey[groupKey])) { - statTable.columns[k].push(v) + statTable.columns[k].data.push(v) } } } - const mappings: any = { + const mappings: HistogramMappings = { xMin: 'xMin', xMax: 'xMax', yMin: 'yMin', @@ -140,7 +159,7 @@ export const bin = ( fill: groupColNames, } - return [statTable, mappings] + return [statTable as HistogramTable, mappings] } const createBins = ( @@ -186,7 +205,7 @@ const getGroup = (table: Table, groupColNames: string[], i: number) => { const result = {} for (const key of groupColNames) { - result[key] = table.columns[key][i] + result[key] = table.columns[key].data[i] } return result diff --git a/ui/src/minard/utils/findHoveredRowIndices.tsx b/ui/src/minard/utils/findHoveredRowIndices.tsx index c87882cc54..8187ebca0a 100644 --- a/ui/src/minard/utils/findHoveredRowIndices.tsx +++ b/ui/src/minard/utils/findHoveredRowIndices.tsx @@ -1,10 +1,10 @@ import {Scale} from 'src/minard' import {range} from 'd3-array' +import {HistogramTable} from 'src/minard' + export const findHoveredRowIndices = ( - xMinCol: number[], - xMaxCol: number[], - yMaxCol: number[], + table: HistogramTable, hoverX: number, hoverY: number, xScale: Scale, @@ -14,6 +14,9 @@ export const findHoveredRowIndices = ( return null } + const xMinCol = table.columns.xMin.data + const xMaxCol = table.columns.xMax.data + const yMaxCol = table.columns.yMax.data const dataX = xScale.invert(hoverX) const dataY = yScale.invert(hoverY) diff --git a/ui/src/minard/utils/getBarFill.ts b/ui/src/minard/utils/getBarFill.ts index d11963b2fd..7abaffa95c 100644 --- a/ui/src/minard/utils/getBarFill.ts +++ b/ui/src/minard/utils/getBarFill.ts @@ -1,4 +1,4 @@ -import {Layer} from 'src/minard' +import {HistogramLayer} from 'src/minard' import {getGroupKey} from 'src/minard/utils/getGroupKey' // Given a histogram `Layer` and the index of row in its table, this function @@ -13,11 +13,11 @@ import {getGroupKey} from 'src/minard/utils/getGroupKey' // key”) that the scale uses as a domain // 3. Lookup the scale and get the color via this representation export const getBarFill = ( - {scales, mappings, table}: Layer, + {scales, mappings, table}: HistogramLayer, i: number ): string => { const fillScale = scales.fill - const values = mappings.fill.map(colKey => table.columns[colKey][i]) + const values = mappings.fill.map(colKey => table.columns[colKey].data[i]) const groupKey = getGroupKey(values) const fill = fillScale(groupKey) diff --git a/ui/src/minard/utils/getHistogramTooltipProps.ts b/ui/src/minard/utils/getHistogramTooltipProps.ts index 1827cd38aa..a125faad46 100644 --- a/ui/src/minard/utils/getHistogramTooltipProps.ts +++ b/ui/src/minard/utils/getHistogramTooltipProps.ts @@ -1,21 +1,22 @@ -import {HistogramTooltipProps, Layer} from 'src/minard' +import {HistogramTooltipProps, HistogramLayer} from 'src/minard' import {getBarFill} from 'src/minard/utils/getBarFill' export const getHistogramTooltipProps = ( - layer: Layer, + layer: HistogramLayer, rowIndices: number[] ): HistogramTooltipProps => { const {table, mappings} = layer - const xMinCol = table.columns[mappings.xMin] - const xMaxCol = table.columns[mappings.xMax] - const yMinCol = table.columns[mappings.yMin] - const yMaxCol = table.columns[mappings.yMax] + + const xMinCol = table.columns.xMin.data + const xMaxCol = table.columns.xMax.data + const yMinCol = table.columns.yMin.data + const yMaxCol = table.columns.yMax.data const counts = rowIndices.map(i => { const grouping = mappings.fill.reduce( (acc, colName) => ({ ...acc, - [colName]: table.columns[colName][i], + [colName]: table.columns[colName].data[i], }), {} ) diff --git a/ui/src/minard/utils/isNumeric.ts b/ui/src/minard/utils/isNumeric.ts new file mode 100644 index 0000000000..1fc5683c30 --- /dev/null +++ b/ui/src/minard/utils/isNumeric.ts @@ -0,0 +1,6 @@ +import {ColumnType} from 'src/minard' + +const NUMERIC_TYPES = new Set(['uint', 'int', 'float', 'time']) + +export const isNumeric = (columnType: ColumnType): boolean => + NUMERIC_TYPES.has(columnType) diff --git a/ui/src/minard/utils/plotEnvActions.ts b/ui/src/minard/utils/plotEnvActions.ts index b28c09f33d..4005622eab 100644 --- a/ui/src/minard/utils/plotEnvActions.ts +++ b/ui/src/minard/utils/plotEnvActions.ts @@ -13,13 +13,13 @@ interface RegisterLayerAction { type: 'REGISTER_LAYER' payload: { layerKey: string - layer: Layer + layer: Partial } } export const registerLayer = ( layerKey: string, - layer: Layer + layer: Partial ): RegisterLayerAction => ({ type: 'REGISTER_LAYER', payload: {layerKey, layer}, diff --git a/ui/src/minard/utils/plotEnvReducer.ts b/ui/src/minard/utils/plotEnvReducer.ts index 990c932ff9..48b8f55829 100644 --- a/ui/src/minard/utils/plotEnvReducer.ts +++ b/ui/src/minard/utils/plotEnvReducer.ts @@ -6,6 +6,7 @@ import chroma from 'chroma-js' import { PlotEnv, Layer, + HistogramLayer, Scale, PLOT_PADDING, TICK_CHAR_WIDTH, @@ -16,6 +17,9 @@ import { import {PlotAction} from 'src/minard/utils/plotEnvActions' import {getGroupKey} from 'src/minard/utils/getGroupKey' +const DEFAULT_X_DOMAIN: [number, number] = [0, 1] +const DEFAULT_Y_DOMAIN: [number, number] = [0, 1] + export const INITIAL_PLOT_ENV: PlotEnv = { width: 0, height: 0, @@ -32,9 +36,15 @@ export const INITIAL_PLOT_ENV: PlotEnv = { xDomain: null, yDomain: null, baseLayer: { - table: {columns: {}, columnTypes: {}}, + type: 'base', + table: {columns: {}, length: 0}, + xDomain: DEFAULT_X_DOMAIN, + yDomain: DEFAULT_Y_DOMAIN, mappings: {}, - scales: {}, + scales: { + x: null, + y: null, + }, }, layers: {}, hoverX: null, @@ -42,16 +52,13 @@ export const INITIAL_PLOT_ENV: PlotEnv = { dispatch: () => {}, } -const DEFAULT_X_DOMAIN: [number, number] = [0, 1] -const DEFAULT_Y_DOMAIN: [number, number] = [0, 1] - export const plotEnvReducer = (state: PlotEnv, action: PlotAction): PlotEnv => produce(state, draftState => { switch (action.type) { case 'REGISTER_LAYER': { const {layerKey, layer} = action.payload - draftState.layers[layerKey] = layer + draftState.layers[layerKey] = {...layer, scales: {}} as Layer setXDomain(draftState) setYDomain(draftState) @@ -133,8 +140,8 @@ const getColumnsForAesthetics = ( if (colName) { const col = layer.table - ? layer.table.columns[colName] - : baseLayer.table.columns[colName] + ? layer.table.columns[colName].data + : baseLayer.table.columns[colName].data cols.push(col) } @@ -273,7 +280,7 @@ const getColorScale = ( of data (for now). So the domain of the scale is a set of "group keys" which represent all possible groupings of data in the layer. */ -const getFillDomain = ({table, mappings}: Layer): string[] => { +const getFillDomain = ({table, mappings}: HistogramLayer): string[] => { const fillColKeys = mappings.fill if (!fillColKeys.length) { @@ -281,10 +288,9 @@ const getFillDomain = ({table, mappings}: Layer): string[] => { } const fillDomain = new Set() - const n = Object.values(table.columns)[0].length - for (let i = 0; i < n; i++) { - fillDomain.add(getGroupKey(fillColKeys.map(k => table.columns[k][i]))) + for (let i = 0; i < table.length; i++) { + fillDomain.add(getGroupKey(fillColKeys.map(k => table.columns[k].data[i]))) } return [...fillDomain].sort() @@ -298,11 +304,8 @@ const setFillScales = (draftState: PlotEnv) => { const layers = Object.values(draftState.layers) layers - .filter( - // Pick out the layers that actually need a fill scale - layer => layer.mappings.fill && layer.colors && layer.colors.length - ) - .forEach(layer => { + .filter(layer => layer.type === 'histogram') + .forEach((layer: HistogramLayer) => { layer.scales.fill = getColorScale(getFillDomain(layer), layer.colors) }) } diff --git a/ui/src/minard/utils/useLayer.ts b/ui/src/minard/utils/useLayer.ts index 222ca2cd72..87b5afb6a4 100644 --- a/ui/src/minard/utils/useLayer.ts +++ b/ui/src/minard/utils/useLayer.ts @@ -10,7 +10,7 @@ import {registerLayer, unregisterLayer} from 'src/minard/utils/plotEnvActions' */ export const useLayer = ( env: PlotEnv, - layerFactory: () => Layer, + layerFactory: () => Partial, inputs?: DependencyList ) => { const {current: layerKey} = useRef(uuid.v4()) diff --git a/ui/src/shared/components/Histogram.tsx b/ui/src/shared/components/Histogram.tsx index 796ce4d882..17305f74cb 100644 --- a/ui/src/shared/components/Histogram.tsx +++ b/ui/src/shared/components/Histogram.tsx @@ -5,8 +5,8 @@ import {AutoSizer} from 'react-virtualized' import { Plot as MinardPlot, Histogram as MinardHistogram, - ColumnType, Table, + isNumeric, } from 'src/minard' // Components @@ -52,18 +52,18 @@ type Props = OwnProps & DispatchProps */ const resolveMappings = ( table: Table, - preferredXColumn: string, - preferredFillColumns: string[] = [] + preferredXColumnName: string, + preferredFillColumnNames: string[] = [] ): {x: string; fill: string[]} => { - let x: string = preferredXColumn + let x: string = preferredXColumnName - if (!table.columns[x] || table.columnTypes[x] !== ColumnType.Numeric) { - x = Object.entries(table.columnTypes) - .filter(([__, type]) => type === ColumnType.Numeric) + if (!table.columns[x] || !isNumeric(table.columns[x].type)) { + x = Object.entries(table.columns) + .filter(([__, {type}]) => isNumeric(type)) .map(([name]) => name)[0] } - let fill = preferredFillColumns || [] + let fill = preferredFillColumnNames || [] fill = fill.filter(name => table.columns[name]) @@ -99,27 +99,33 @@ const Histogram: SFC = ({ return ( - {({width, height}) => ( - - {env => ( - - )} - - )} + {({width, height}) => { + if (width === 0 || height === 0) { + return null + } + + return ( + + {env => ( + + )} + + ) + }} ) } diff --git a/ui/src/shared/utils/toMinardTable.test.ts b/ui/src/shared/utils/toMinardTable.test.ts index 7f9ac152dd..a7b8cbb19e 100644 --- a/ui/src/shared/utils/toMinardTable.test.ts +++ b/ui/src/shared/utils/toMinardTable.test.ts @@ -20,31 +20,42 @@ describe('toMinardTable', () => { const tables = parseResponse(CSV) const actual = toMinardTable(tables) const expected = { - schemaConflicts: [], table: { - columnTypes: { - _field: 'categorical', - _measurement: 'categorical', - _start: 'temporal', - _stop: 'temporal', - _time: 'temporal', - _value: 'numeric', - cpu: 'categorical', - host: 'categorical', - result: 'categorical', - }, columns: { - _field: ['usage_guest', 'usage_guest', 'usage_guest', 'usage_guest'], - _measurement: ['cpu', 'cpu', 'cpu', 'cpu'], - _start: [1549064312524, 1549064312524, 1549064312524, 1549064312524], - _stop: [1549064342524, 1549064342524, 1549064342524, 1549064342524], - _time: [1549064313000, 1549064323000, 1549064313000, 1549064323000], - _value: [10, 20, 30, 40], - cpu: ['cpu-total', 'cpu-total', 'cpu0', 'cpu0'], - host: ['oox4k.local', 'oox4k.local', 'oox4k.local', 'oox4k.local'], - result: ['_result', '_result', '_result', '_result'], + result: { + data: ['_result', '_result', '_result', '_result'], + type: 'string', + }, + _start: { + data: [1549064312524, 1549064312524, 1549064312524, 1549064312524], + type: 'time', + }, + _stop: { + data: [1549064342524, 1549064342524, 1549064342524, 1549064342524], + type: 'time', + }, + _time: { + data: [1549064313000, 1549064323000, 1549064313000, 1549064323000], + type: 'time', + }, + _value: {data: [10, 20, 30, 40], type: 'float'}, + _field: { + data: ['usage_guest', 'usage_guest', 'usage_guest', 'usage_guest'], + type: 'string', + }, + _measurement: {data: ['cpu', 'cpu', 'cpu', 'cpu'], type: 'string'}, + cpu: { + data: ['cpu-total', 'cpu-total', 'cpu0', 'cpu0'], + type: 'string', + }, + host: { + data: ['oox4k.local', 'oox4k.local', 'oox4k.local', 'oox4k.local'], + type: 'string', + }, }, + length: 4, }, + schemaConflicts: [], } expect(actual).toEqual(expected) @@ -68,31 +79,42 @@ describe('toMinardTable', () => { const tables = parseResponse(CSV) const actual = toMinardTable(tables) const expected = { - schemaConflicts: ['_value'], table: { - columnTypes: { - _field: 'categorical', - _measurement: 'categorical', - _start: 'temporal', - _stop: 'temporal', - _time: 'temporal', - _value: 'numeric', - cpu: 'categorical', - host: 'categorical', - result: 'categorical', - }, columns: { - _field: ['usage_guest', 'usage_guest', 'usage_guest', 'usage_guest'], - _measurement: ['cpu', 'cpu', 'cpu', 'cpu'], - _start: [1549064312524, 1549064312524, 1549064312524, 1549064312524], - _stop: [1549064342524, 1549064342524, 1549064342524, 1549064342524], - _time: [1549064313000, 1549064323000, 1549064313000, 1549064323000], - _value: [10, 20, undefined, undefined], - cpu: ['cpu-total', 'cpu-total', 'cpu0', 'cpu0'], - host: ['oox4k.local', 'oox4k.local', 'oox4k.local', 'oox4k.local'], - result: ['_result', '_result', '_result', '_result'], + result: { + data: ['_result', '_result', '_result', '_result'], + type: 'string', + }, + _start: { + data: [1549064312524, 1549064312524, 1549064312524, 1549064312524], + type: 'time', + }, + _stop: { + data: [1549064342524, 1549064342524, 1549064342524, 1549064342524], + type: 'time', + }, + _time: { + data: [1549064313000, 1549064323000, 1549064313000, 1549064323000], + type: 'time', + }, + _value: {data: [10, 20, undefined, undefined], type: 'float'}, + _field: { + data: ['usage_guest', 'usage_guest', 'usage_guest', 'usage_guest'], + type: 'string', + }, + _measurement: {data: ['cpu', 'cpu', 'cpu', 'cpu'], type: 'string'}, + cpu: { + data: ['cpu-total', 'cpu-total', 'cpu0', 'cpu0'], + type: 'string', + }, + host: { + data: ['oox4k.local', 'oox4k.local', 'oox4k.local', 'oox4k.local'], + type: 'string', + }, }, + length: 4, }, + schemaConflicts: ['_value'], } expect(actual).toEqual(expected) diff --git a/ui/src/shared/utils/toMinardTable.ts b/ui/src/shared/utils/toMinardTable.ts index 13c2bbb1cf..71dbde91e2 100644 --- a/ui/src/shared/utils/toMinardTable.ts +++ b/ui/src/shared/utils/toMinardTable.ts @@ -1,5 +1,5 @@ import {FluxTable} from 'src/types' -import {Table, ColumnType} from 'src/minard' +import {Table, ColumnType, isNumeric} from 'src/minard' export const GROUP_KEY_COL_NAME = 'group_key' @@ -53,8 +53,7 @@ export interface ToMinardTableResult { */ export const toMinardTable = (tables: FluxTable[]): ToMinardTableResult => { - const columns = {} - const columnTypes = {} + const outColumns = {} const schemaConflicts = [] let k = 0 @@ -68,34 +67,37 @@ export const toMinardTable = (tables: FluxTable[]): ToMinardTableResult => { } for (let j = 0; j < header.length; j++) { - const column = header[j] + const columnName = header[j] - if (column === '' || column === 'table') { + if (columnName === '' || columnName === 'table') { // Ignore these columns continue } - const columnType = toMinardColumnType(table.dataTypes[column]) + const columnType = toMinardColumnType(table.dataTypes[columnName]) + let columnConflictsSchema = false - if (columnTypes[column] && columnTypes[column] !== columnType) { - schemaConflicts.push(column) + if ( + outColumns[columnName] && + outColumns[columnName].type !== columnType + ) { + schemaConflicts.push(columnName) columnConflictsSchema = true - } else if (!columnTypes[column]) { - columns[column] = [] - columnTypes[column] = columnType + } else if (!outColumns[columnName]) { + outColumns[columnName] = {data: [], type: columnType} } for (let i = 1; i < table.data.length; i++) { let value - if (column === 'result') { + if (columnName === 'result') { value = table.result } else if (!columnConflictsSchema) { value = parseValue(table.data[i][j].trim(), columnType) } - columns[column][k + i - 1] = value + outColumns[columnName].data[k + i - 1] = value } } @@ -103,7 +105,7 @@ export const toMinardTable = (tables: FluxTable[]): ToMinardTableResult => { } const result: ToMinardTableResult = { - table: {columns, columnTypes}, + table: {columns: outColumns, length: k}, schemaConflicts, } @@ -111,12 +113,12 @@ export const toMinardTable = (tables: FluxTable[]): ToMinardTableResult => { } const TO_MINARD_COLUMN_TYPE = { - boolean: ColumnType.Boolean, - unsignedLong: ColumnType.Numeric, - long: ColumnType.Numeric, - double: ColumnType.Numeric, - string: ColumnType.Categorical, - 'dateTime:RFC3339': ColumnType.Temporal, + boolean: 'bool', + unsignedLong: 'uint', + long: 'int', + double: 'float', + string: 'string', + 'dateTime:RFC3339': 'time', } const toMinardColumnType = (fluxDataType: string): ColumnType => { @@ -138,24 +140,24 @@ const parseValue = (value: string, columnType: ColumnType): any => { return NaN } - if (columnType === ColumnType.Boolean && value === 'true') { + if (columnType === 'bool' && value === 'true') { return true } - if (columnType === ColumnType.Boolean && value === 'false') { + if (columnType === 'bool' && value === 'false') { return false } - if (columnType === ColumnType.Categorical) { + if (columnType === 'string') { return value } - if (columnType === ColumnType.Numeric) { - return Number(value) + if (columnType === 'time') { + return Date.parse(value) } - if (columnType === ColumnType.Temporal) { - return Date.parse(value) + if (isNumeric(columnType)) { + return Number(value) } return null diff --git a/ui/src/timeMachine/actions/index.ts b/ui/src/timeMachine/actions/index.ts index 28731a5de2..eb9ca7c44f 100644 --- a/ui/src/timeMachine/actions/index.ts +++ b/ui/src/timeMachine/actions/index.ts @@ -15,7 +15,7 @@ import { } from 'src/types/v2/dashboards' import {TimeMachineTab} from 'src/types/v2/timeMachine' import {Color} from 'src/types/colors' -import {Table, HistogramPosition, ColumnType} from 'src/minard' +import {Table, HistogramPosition, isNumeric} from 'src/minard' export type Action = | QueryBuilderAction @@ -511,8 +511,8 @@ interface TableLoadedAction { } export const tableLoaded = (table: Table): TableLoadedAction => { - const availableXColumns = Object.entries(table.columnTypes) - .filter(([__, type]) => type === ColumnType.Numeric) + const availableXColumns = Object.entries(table.columns) + .filter(([__, {type}]) => isNumeric(type) && type !== 'time') .map(([name]) => name) const invalidGroupColumns = new Set(['_value', '_start', '_stop', '_time']) From 9bad05e017d61389238462ea5a644e371642b565 Mon Sep 17 00:00:00 2001 From: Daniel Campbell Date: Fri, 22 Feb 2019 15:23:17 -0800 Subject: [PATCH 34/54] date picker styles (#12126) * date picker styles * get lint --- .../components/dateRangePicker/DatePicker.tsx | 56 ++++++++++--------- .../dateRangePicker/DateRangePicker.scss | 32 ++++++++--- .../dateRangePicker/DateRangePicker.tsx | 2 +- 3 files changed, 54 insertions(+), 36 deletions(-) diff --git a/ui/src/shared/components/dateRangePicker/DatePicker.tsx b/ui/src/shared/components/dateRangePicker/DatePicker.tsx index 06335ef701..d78b8176b1 100644 --- a/ui/src/shared/components/dateRangePicker/DatePicker.tsx +++ b/ui/src/shared/components/dateRangePicker/DatePicker.tsx @@ -4,9 +4,8 @@ import ReactDatePicker from 'react-datepicker' // Styles import 'react-datepicker/dist/react-datepicker.css' -import {Input} from 'src/clockface' -import {ComponentSize} from '@influxdata/clockface' -import FormLabel from 'src/clockface/components/form_layout/FormLabel' +import {Input, Form, Grid} from 'src/clockface' +import {ComponentSize, Columns} from '@influxdata/clockface' interface Props { label: string @@ -22,37 +21,42 @@ class DatePicker extends PureComponent { const date = new Date(dateTime) return ( - -
- -
-
+
+ + + + + + + +
) } private get customInput() { + const {label} = this.props + return ( ) } diff --git a/ui/src/shared/components/dateRangePicker/DateRangePicker.scss b/ui/src/shared/components/dateRangePicker/DateRangePicker.scss index f36d22586f..1b2a1df2e1 100644 --- a/ui/src/shared/components/dateRangePicker/DateRangePicker.scss +++ b/ui/src/shared/components/dateRangePicker/DateRangePicker.scss @@ -10,10 +10,10 @@ text-align: center; background-color: $g1-raven; border: $ix-border solid $c-pool; - padding: $ix-marg-b; + padding: 0 $ix-marg-b; border-radius: $ix-radius; z-index: 9999; - height: 410px; + height: 416px; .react-datepicker { font-family: $ix-text-font; @@ -25,9 +25,16 @@ display: flex; flex-direction: row; align-items: center; - margin: $ix-marg-b 0; - + margin-top: $ix-marg-b; + .range-picker--date-picker { + margin: $ix-marg-a; + + .react-datepicker-wrapper, + .react-datepicker__input-container { + width: 100%; + } + .range-picker--popper-container { position: relative; } @@ -44,6 +51,12 @@ display: inline-flex; flex-direction: row; + .react-datepicker__month-container { + background-color: $g3-castle; + border-radius: 0 0 $ix-radius $ix-radius; + width: 260px; + } + .react-datepicker__navigation { outline: none; cursor: pointer; @@ -58,8 +71,7 @@ } .range-picker--day { - color: $c-void; - font-weight: 400; + color: $g7-graphite; &:hover { background-color: $c-laser; @@ -68,7 +80,7 @@ } .range-picker--day-in-month { - color: $c-star; + color: $g14-chromium; &:hover { background-color: $c-laser; @@ -86,6 +98,7 @@ } .react-datepicker__header { + border-radius: 0; padding: 0; border: none; @@ -93,6 +106,7 @@ .react-datepicker__day-name { color: $c-rainforest; + font-weight: 700; } .react-datepicker__current-month { @@ -138,8 +152,8 @@ .react-datepicker__time-box { width: 100%; - background-color: $g2-kevlar; - color: $g18-cloud; + background-color: $g3-castle; + color: $g14-chromium; .react-datepicker__time-list { font-size: $ix-text-base; diff --git a/ui/src/shared/components/dateRangePicker/DateRangePicker.tsx b/ui/src/shared/components/dateRangePicker/DateRangePicker.tsx index c8215845f5..26eb800f92 100644 --- a/ui/src/shared/components/dateRangePicker/DateRangePicker.tsx +++ b/ui/src/shared/components/dateRangePicker/DateRangePicker.tsx @@ -26,7 +26,7 @@ interface State { topPosition?: number } -const PICKER_HEIGHT = 410 +const PICKER_HEIGHT = 416 const HORIZONTAL_PADDING = 2 const VERTICAL_PADDING = 15 From 83ba3e9c22460cad30758ed318111d46d28cf576 Mon Sep 17 00:00:00 2001 From: Julius Volz Date: Fri, 22 Feb 2019 14:01:46 +0100 Subject: [PATCH 35/54] Check error when writing token file during setup Signed-off-by: Julius Volz --- cmd/influx/setup.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/influx/setup.go b/cmd/influx/setup.go index 7221eadc5f..c1694b64ff 100644 --- a/cmd/influx/setup.go +++ b/cmd/influx/setup.go @@ -71,7 +71,11 @@ func setupF(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to setup instance: %v", err) } - writeTokenToPath(result.Auth.Token, defaultTokenPath()) + err = writeTokenToPath(result.Auth.Token, defaultTokenPath()) + if err != nil { + return fmt.Errorf("failed to write token to path %q: %v", defaultTokenPath(), err) + } + fmt.Println(promptWithColor("Your token has been stored in "+defaultTokenPath()+".", colorCyan)) w := internal.NewTabWriter(os.Stdout) From 380cc2285c5ec19fb6cc3d90e1440932083565df Mon Sep 17 00:00:00 2001 From: Mark Rushakoff Date: Thu, 21 Feb 2019 21:59:46 -0800 Subject: [PATCH 36/54] fix(swagger): quote keys named "y" The YAML parser used by the go-openapi libraries treats an unquoted y as a boolean key, which will lead to a difficult-to-understand parser error: types don't match expect map key string or int got: bool See also https://yaml.org/type/bool.html. --- http/swagger.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/http/swagger.yml b/http/swagger.yml index 5113196721..08f133b9fa 100644 --- a/http/swagger.yml +++ b/http/swagger.yml @@ -5819,7 +5819,7 @@ components: properties: x: $ref: '#/components/schemas/Axis' - y: + "y": # Quoted to prevent YAML parser from interpreting y as shorthand for true. $ref: '#/components/schemas/Axis' y2: $ref: '#/components/schemas/Axis' @@ -6051,7 +6051,7 @@ components: x: type: integer format: int32 - y: + "y": # Quoted to prevent YAML parser from interpreting y as shorthand for true. type: integer format: int32 w: @@ -6099,7 +6099,7 @@ components: x: type: integer format: int32 - y: + "y": # Quoted to prevent YAML parser from interpreting y as shorthand for true. type: integer format: int32 w: From 8d4827cae79cbe9bdebc8e7127f0ffc193ab5487 Mon Sep 17 00:00:00 2001 From: Lyon Hill Date: Fri, 22 Feb 2019 09:47:04 -0700 Subject: [PATCH 37/54] Allow tasks to skip catchup (#12068) --- cmd/influxd/launcher/launcher.go | 2 +- task/backend/bolt/bolt.go | 29 +++++++++- task/backend/bolt/bolt_test.go | 92 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/cmd/influxd/launcher/launcher.go b/cmd/influxd/launcher/launcher.go index 0355a5194c..56aa622c8c 100644 --- a/cmd/influxd/launcher/launcher.go +++ b/cmd/influxd/launcher/launcher.go @@ -442,7 +442,7 @@ func (m *Launcher) run(ctx context.Context) (err error) { store taskbackend.Store err error ) - store, err = taskbolt.New(m.boltClient.DB(), "tasks") + store, err = taskbolt.New(m.boltClient.DB(), "tasks", taskbolt.NoCatchUp) if err != nil { m.logger.Error("failed opening task bolt", zap.Error(err)) return err diff --git a/task/backend/bolt/bolt.go b/task/backend/bolt/bolt.go index 05650b5e7d..a938622f92 100644 --- a/task/backend/bolt/bolt.go +++ b/task/backend/bolt/bolt.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "math" "time" bolt "github.com/coreos/bbolt" @@ -46,6 +47,8 @@ type Store struct { db *bolt.DB bucket []byte idGen platform.IDGenerator + + minLatestCompleted int64 } const basePath = "/tasks/v1/" @@ -59,8 +62,14 @@ var ( runIDs = []byte(basePath + "run_ids") ) +// Option is a optional configuration for the store. +type Option func(*Store) + +// NoCatchUp allows you to skip any task that was supposed to run during down time. +func NoCatchUp(st *Store) { st.minLatestCompleted = time.Now().Unix() } + // New gives us a new Store based on "github.com/coreos/bbolt" -func New(db *bolt.DB, rootBucket string) (*Store, error) { +func New(db *bolt.DB, rootBucket string, opts ...Option) (*Store, error) { if db.IsReadOnly() { return nil, ErrDBReadOnly } @@ -87,7 +96,11 @@ func New(db *bolt.DB, rootBucket string) (*Store, error) { if err != nil { return nil, err } - return &Store{db: db, bucket: bucket, idGen: snowflake.NewDefaultIDGenerator()}, nil + st := &Store{db: db, bucket: bucket, idGen: snowflake.NewDefaultIDGenerator(), minLatestCompleted: math.MinInt64} + for _, opt := range opts { + opt(st) + } + return st, nil } // CreateTask creates a task in the boltdb task store. @@ -434,6 +447,10 @@ func (s *Store) FindTaskMetaByID(ctx context.Context, id platform.ID) (*backend. return nil, err } + if stm.LatestCompleted < s.minLatestCompleted { + stm.LatestCompleted = s.minLatestCompleted + } + return &stm, nil } @@ -472,6 +489,10 @@ func (s *Store) FindTaskByIDWithMeta(ctx context.Context, id platform.ID) (*back return nil, nil, err } + if stm.LatestCompleted < s.minLatestCompleted { + stm.LatestCompleted = s.minLatestCompleted + } + return &backend.StoreTask{ ID: id, Org: orgID, @@ -539,6 +560,10 @@ func (s *Store) CreateNextRun(ctx context.Context, taskID platform.ID, now int64 return err } + if stm.LatestCompleted < s.minLatestCompleted { + stm.LatestCompleted = s.minLatestCompleted + } + rc, err = stm.CreateNextRun(now, func() (platform.ID, error) { return s.idGen.ID(), nil }) diff --git a/task/backend/bolt/bolt_test.go b/task/backend/bolt/bolt_test.go index c86f26d93e..bb9c9ba3ca 100644 --- a/task/backend/bolt/bolt_test.go +++ b/task/backend/bolt/bolt_test.go @@ -1,11 +1,14 @@ package bolt_test import ( + "context" "io/ioutil" "os" "testing" + "time" bolt "github.com/coreos/bbolt" + "github.com/influxdata/influxdb" _ "github.com/influxdata/influxdb/query/builtin" "github.com/influxdata/influxdb/task/backend" boltstore "github.com/influxdata/influxdb/task/backend/bolt" @@ -49,3 +52,92 @@ func TestBoltStore(t *testing.T) { }, )(t) } + +func TestSkip(t *testing.T) { + f, err := ioutil.TempFile("", "influx_bolt_task_store_test") + if err != nil { + t.Fatalf("failed to create tempfile for test db %v\n", err) + } + defer f.Close() + defer os.Remove(f.Name()) + + db, err := bolt.Open(f.Name(), os.ModeTemporary, nil) + if err != nil { + t.Fatalf("failed to open bolt db for test db %v\n", err) + } + s, err := boltstore.New(db, "testbucket") + if err != nil { + t.Fatalf("failed to create new bolt store %v\n", err) + } + + schedAfter := time.Now().Add(-time.Minute) + tskID, err := s.CreateTask(context.Background(), backend.CreateTaskRequest{ + Org: influxdb.ID(1), + AuthorizationID: influxdb.ID(2), + Script: `option task = {name:"x", every:1s} from(bucket:"b-src") |> range(start:-1m) |> to(bucket:"b-dst", org:"o")`, + ScheduleAfter: schedAfter.Unix(), + Status: backend.TaskActive, + }) + if err != nil { + t.Fatalf("failed to create new task %v\n", err) + } + + rc, err := s.CreateNextRun(context.Background(), tskID, schedAfter.Add(10*time.Second).Unix()) + if err != nil { + t.Fatalf("failed to create new run %v\n", err) + } + + if err := s.FinishRun(context.Background(), tskID, rc.Created.RunID); err != nil { + t.Fatalf("failed to finish run %v\n", err) + } + + meta, err := s.FindTaskMetaByID(context.Background(), tskID) + if err != nil { + t.Fatalf("failed to pull meta %v\n", err) + } + if meta.LatestCompleted <= schedAfter.Unix() { + t.Fatal("failed to update latestCompleted") + } + + latestCompleted := meta.LatestCompleted + + s.Close() + + db, err = bolt.Open(f.Name(), os.ModeTemporary, nil) + if err != nil { + t.Fatalf("failed to open bolt db for test db %v\n", err) + } + s, err = boltstore.New(db, "testbucket", boltstore.NoCatchUp) + if err != nil { + t.Fatalf("failed to create new bolt store %v\n", err) + } + defer s.Close() + + meta, err = s.FindTaskMetaByID(context.Background(), tskID) + if err != nil { + t.Fatalf("failed to pull meta %v\n", err) + } + + if meta.LatestCompleted == latestCompleted { + t.Fatal("failed to overwrite latest completed on new meta pull") + } + latestCompleted = meta.LatestCompleted + + rc, err = s.CreateNextRun(context.Background(), tskID, time.Now().Add(10*time.Second).Unix()) + if err != nil { + t.Fatalf("failed to create new run %v\n", err) + } + + if err := s.FinishRun(context.Background(), tskID, rc.Created.RunID); err != nil { + t.Fatalf("failed to finish run %v\n", err) + } + + meta, err = s.FindTaskMetaByID(context.Background(), tskID) + if err != nil { + t.Fatalf("failed to pull meta %v\n", err) + } + + if meta.LatestCompleted == latestCompleted { + t.Fatal("failed to run after an override") + } +} From 609b1d32b69880642791ce910aeb1357f35e0bf0 Mon Sep 17 00:00:00 2001 From: Mark Rushakoff Date: Thu, 21 Feb 2019 14:21:36 -0800 Subject: [PATCH 38/54] fix(task): create authorizations for tasks, which can read their task Also set the generated token's description while we're here. This enables us to use task's Authorization when we need to query the system bucket to get run logs, etc. but we only have a Session. --- http/task_service.go | 86 +++++++++++++++++++++++++++++++++++---- http/task_service_test.go | 67 ++++++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 11 deletions(-) diff --git a/http/task_service.go b/http/task_service.go index aa93d30169..9641a73df7 100644 --- a/http/task_service.go +++ b/http/task_service.go @@ -19,6 +19,7 @@ import ( pcontext "github.com/influxdata/influxdb/context" "github.com/influxdata/influxdb/query" "github.com/influxdata/influxdb/task/backend" + "github.com/influxdata/influxdb/task/options" "github.com/julienschmidt/httprouter" "go.uber.org/zap" ) @@ -369,44 +370,98 @@ func decodeGetTasksRequest(ctx context.Context, r *http.Request) (*getTasksReque return req, nil } -func (h *TaskHandler) createTaskAuthorizationIfNotExists(ctx context.Context, a platform.Authorizer, t *platform.TaskCreate) error { +// createBootstrapTaskAuthorizationIfNotExists checks if a the task create request hasn't specified a token, and if the request came from a session, +// and if both of those are true, it creates an authorization and return it. +// +// Note that the created authorization will have permissions required for the task, +// but it won't have permissions to read the task, as we don't have the task ID yet. +// +// This method may return a nil error and a nil authorization, if there wasn't a need to create an authorization. +func (h *TaskHandler) createBootstrapTaskAuthorizationIfNotExists(ctx context.Context, a platform.Authorizer, t *platform.TaskCreate) (*platform.Authorization, error) { if t.Token != "" { - return nil + return nil, nil } s, ok := a.(*platform.Session) if !ok { // If an authorization was used continue - return nil + return nil, nil } spec, err := flux.Compile(ctx, t.Flux, time.Now()) if err != nil { - return err + return nil, err } preAuthorizer := query.NewPreAuthorizer(h.BucketService) ps, err := preAuthorizer.RequiredPermissions(ctx, spec) if err != nil { - return err + return nil, err } if err := authorizer.VerifyPermissions(ctx, ps); err != nil { - return err + return nil, err + } + + opts, err := options.FromScript(t.Flux) + if err != nil { + return nil, err } auth := &platform.Authorization{ OrgID: t.OrganizationID, UserID: s.UserID, Permissions: ps, + Description: fmt.Sprintf("bootstrap authorization for task %q", opts.Name), } if err := h.AuthorizationService.CreateAuthorization(ctx, auth); err != nil { - return err + return nil, err } t.Token = auth.Token + return auth, nil +} + +func (h *TaskHandler) finalizeBootstrappedTaskAuthorization(ctx context.Context, bootstrap *platform.Authorization, task *platform.Task) error { + // If we created a bootstrapped authorization for a task, + // we need to replace it with a new authorization that allows read access on the task. + // Unfortunately for this case, updating authorizations is not allowed. + readTaskPerm, err := platform.NewPermissionAtID(task.ID, platform.ReadAction, platform.TasksResourceType, bootstrap.OrgID) + if err != nil { + // We should never fail to create a new permission like this. + return err + } + authzWithTask := &platform.Authorization{ + UserID: bootstrap.UserID, + OrgID: bootstrap.OrgID, + Permissions: append([]platform.Permission{*readTaskPerm}, bootstrap.Permissions...), + Description: fmt.Sprintf("auto-generated authorization for task %q", task.Name), + } + + if err := h.AuthorizationService.CreateAuthorization(ctx, authzWithTask); err != nil { + h.logger.Warn("Failed to finalize bootstrap authorization", zap.String("taskID", task.ID.String())) + // The task exists with an authorization that can't read the task. + return err + } + + // Assign the new authorization... + u, err := h.TaskService.UpdateTask(ctx, task.ID, platform.TaskUpdate{Token: authzWithTask.Token}) + if err != nil { + h.logger.Warn("Failed to assign finalized authorization", zap.String("authorizationID", bootstrap.ID.String()), zap.String("taskID", task.ID.String())) + // The task exists with an authorization that can't read the task, + // and we've created a new authorization for the task but not assigned it. + return err + } + *task = *u + + // .. and delete the old one. + if err := h.AuthorizationService.DeleteAuthorization(ctx, bootstrap.ID); err != nil { + // Since this is the last thing we're doing, just log it if we fail to delete for some reason. + h.logger.Warn("Failed to delete bootstrap authorization", zap.String("authorizationID", bootstrap.ID.String()), zap.String("taskID", task.ID.String())) + } + return nil } @@ -453,7 +508,8 @@ func (h *TaskHandler) handlePostTask(w http.ResponseWriter, r *http.Request) { return } - if err := h.createTaskAuthorizationIfNotExists(ctx, auth, &req.TaskCreate); err != nil { + bootstrapAuthz, err := h.createBootstrapTaskAuthorizationIfNotExists(ctx, auth, &req.TaskCreate) + if err != nil { EncodeError(ctx, err, w) return } @@ -471,6 +527,20 @@ func (h *TaskHandler) handlePostTask(w http.ResponseWriter, r *http.Request) { return } + if bootstrapAuthz != nil { + // There was a bootstrapped authorization for this task. + // Now we need to apply the final authorization for the task. + if err := h.finalizeBootstrappedTaskAuthorization(ctx, bootstrapAuthz, task); err != nil { + err = &platform.Error{ + Err: err, + Msg: fmt.Sprintf("successfully created task with ID %s, but failed to finalize bootstrap token for task", task.ID.String()), + Code: platform.EInternal, + } + EncodeError(ctx, err, w) + return + } + } + if err := encodeResponse(ctx, w, http.StatusCreated, newTaskResponse(*task, []*platform.Label{})); err != nil { logEncodingError(h.logger, r, err) return diff --git a/http/task_service_test.go b/http/task_service_test.go index 131e02cb94..5778f8a2ef 100644 --- a/http/task_service_test.go +++ b/http/task_service_test.go @@ -900,16 +900,27 @@ func TestService_handlePostTaskLabel(t *testing.T) { } func TestTaskHandler_CreateTaskFromSession(t *testing.T) { + i := inmem.NewService() + + taskID := platform.ID(9) var createdTasks []platform.TaskCreate ts := &mock.TaskService{ CreateTaskFn: func(_ context.Context, tc platform.TaskCreate) (*platform.Task, error) { createdTasks = append(createdTasks, tc) // Task with fake IDs so it can be serialized. - return &platform.Task{ID: 9, OrganizationID: 99, AuthorizationID: 999}, nil + return &platform.Task{ID: taskID, OrganizationID: 99, AuthorizationID: 999, Name: "x"}, nil + }, + // Needed due to task authorization bootstrapping. + UpdateTaskFn: func(ctx context.Context, id platform.ID, tu platform.TaskUpdate) (*platform.Task, error) { + authz, err := i.FindAuthorizationByToken(ctx, tu.Token) + if err != nil { + t.Fatal(err) + } + + return &platform.Task{ID: taskID, OrganizationID: 99, AuthorizationID: authz.ID, Name: "x"}, nil }, } - i := inmem.NewService() h := NewTaskHandler(&TaskBackend{ Logger: zaptest.NewLogger(t), @@ -992,7 +1003,57 @@ func TestTaskHandler_CreateTaskFromSession(t *testing.T) { } // The task should have been created with a valid token. - if _, err := i.FindAuthorizationByToken(ctx, createdTasks[0].Token); err != nil { + var createdTask platform.Task + if err := json.Unmarshal([]byte(body), &createdTask); err != nil { t.Fatal(err) } + authz, err := i.FindAuthorizationByID(ctx, createdTask.AuthorizationID) + if err != nil { + t.Fatal(err) + } + if authz.UserID != u.ID { + t.Fatalf("expected authorization to be associated with user %v, got %v", u.ID, authz.UserID) + } + if authz.OrgID != o.ID { + t.Fatalf("expected authorization to be associated with org %v, got %v", o.ID, authz.OrgID) + } + const expDesc = `auto-generated authorization for task "x"` + if authz.Description != expDesc { + t.Fatalf("expected authorization to be created with description %q, got %q", expDesc, authz.Description) + } + + // The authorization should be allowed to read and write the target buckets, + // and it should be allowed to read its task. + if !authz.Allowed(platform.Permission{ + Action: platform.ReadAction, + Resource: platform.Resource{ + Type: platform.BucketsResourceType, + OrgID: &o.ID, + ID: &bSrc.ID, + }, + }) { + t.Logf("WARNING: permissions on `from` buckets not yet accessible: update test after https://github.com/influxdata/flux/issues/114 is fixed.") + } + + if !authz.Allowed(platform.Permission{ + Action: platform.WriteAction, + Resource: platform.Resource{ + Type: platform.BucketsResourceType, + OrgID: &o.ID, + ID: &bDst.ID, + }, + }) { + t.Fatalf("expected authorization to be allowed write access to destination bucket, but it wasn't allowed") + } + + if !authz.Allowed(platform.Permission{ + Action: platform.ReadAction, + Resource: platform.Resource{ + Type: platform.TasksResourceType, + OrgID: &o.ID, + ID: &taskID, + }, + }) { + t.Fatalf("expected authorization to be allowed to read its task, but it wasn't allowed") + } } From e3f430f463c798021df7c677b3ba7f27a5c70728 Mon Sep 17 00:00:00 2001 From: Nathaniel Cook Date: Fri, 22 Feb 2019 10:11:41 -0700 Subject: [PATCH 39/54] fix(http): separate InfluxQL and Flux services Previously the APIBackend understood only a ProxyQueryService, but it needs to understand that there are two implementations of the ProxyQueryService one for handling InfluxQL queries and one for handling Flux queries. The names of the fields have been updated to make this clear. As well as the FluxBackend is now initialized using the FluxService explicitly. --- cmd/influxd/launcher/launcher.go | 3 ++- http/api_handler.go | 3 ++- http/query_handler.go | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd/influxd/launcher/launcher.go b/cmd/influxd/launcher/launcher.go index 56aa622c8c..d7883823ab 100644 --- a/cmd/influxd/launcher/launcher.go +++ b/cmd/influxd/launcher/launcher.go @@ -535,7 +535,8 @@ func (m *Launcher) run(ctx context.Context) (err error) { VariableService: variableSvc, PasswordsService: passwdsSvc, OnboardingService: onboardingSvc, - ProxyQueryService: storageQueryService, + InfluxQLService: nil, // No InfluxQL support + FluxService: storageQueryService, TaskService: taskSvc, TelegrafService: telegrafSvc, ScraperTargetStoreService: scraperTargetSvc, diff --git a/http/api_handler.go b/http/api_handler.go index 7b5d0d81be..f9888c5b96 100644 --- a/http/api_handler.go +++ b/http/api_handler.go @@ -61,7 +61,8 @@ type APIBackend struct { VariableService influxdb.VariableService PasswordsService influxdb.PasswordsService OnboardingService influxdb.OnboardingService - ProxyQueryService query.ProxyQueryService + InfluxQLService query.ProxyQueryService + FluxService query.ProxyQueryService TaskService influxdb.TaskService TelegrafService influxdb.TelegrafConfigStore ScraperTargetStoreService influxdb.ScraperTargetStoreService diff --git a/http/query_handler.go b/http/query_handler.go index 9318242fdf..64c417f604 100644 --- a/http/query_handler.go +++ b/http/query_handler.go @@ -42,7 +42,7 @@ func NewFluxBackend(b *APIBackend) *FluxBackend { return &FluxBackend{ Logger: b.Logger.With(zap.String("handler", "query")), - ProxyQueryService: b.ProxyQueryService, + ProxyQueryService: b.FluxService, OrganizationService: b.OrganizationService, } } From 916d17a27373790eac7eeefdd53cce6f4eaf58c9 Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Thu, 21 Feb 2019 11:18:26 -0800 Subject: [PATCH 40/54] feat(ui): Clone a task's labels when cloning the task Move task cloning logic to external client library --- CHANGELOG.md | 1 + ui/package-lock.json | 47 ++++++++++++++++++++++---------- ui/package.json | 2 +- ui/src/tasks/actions/v2/index.ts | 4 +-- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce9bfd7c1c..7f29fb633f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## v2.0.0-alpha.5 [unreleased] ### Features +1. [12096](https://github.com/influxdata/influxdb/pull/12096): Add labels to cloned tasks ### Bug Fixes diff --git a/ui/package-lock.json b/ui/package-lock.json index 0b16870c24..6d4b1ddfe6 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -985,9 +985,9 @@ } }, "@influxdata/influx": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/@influxdata/influx/-/influx-0.2.15.tgz", - "integrity": "sha512-4s3yLEYdiauq0eydi35GrxTOs55ghpRiBiNFKuH5kTGOrXj9y9OSxJfMLyE+Dy4s4FD/Z+UpeBM2Uy3dRdzerg==", + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@influxdata/influx/-/influx-0.2.18.tgz", + "integrity": "sha512-GMkSinELOnOJMuupd/7H4CwOEWqvVTj3863tgH/b7HBRDSZyi/FqYUPazEYSoMXPQA65oPhcqnbgYAUtC1foWw==", "requires": { "axios": "^0.18.0" } @@ -6085,7 +6085,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -6109,13 +6110,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6132,19 +6135,22 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -6275,7 +6281,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -6289,6 +6296,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -6305,6 +6313,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -6313,13 +6322,15 @@ "version": "0.0.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.2.4.tgz", "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==", "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -6340,6 +6351,7 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -6428,7 +6440,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -6442,6 +6455,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -6537,7 +6551,8 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -6579,6 +6594,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -6600,6 +6616,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -6648,13 +6665,15 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz", "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=", - "dev": true + "dev": true, + "optional": true } } }, diff --git a/ui/package.json b/ui/package.json index 06ab2479cc..0337581cd5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -137,7 +137,7 @@ }, "dependencies": { "@influxdata/clockface": "0.0.5", - "@influxdata/influx": "0.2.15", + "@influxdata/influx": "0.2.18", "@influxdata/react-custom-scrollbars": "4.3.8", "axios": "^0.18.0", "babel-polyfill": "^6.26.0", diff --git a/ui/src/tasks/actions/v2/index.ts b/ui/src/tasks/actions/v2/index.ts index 8a5b4e1965..8bfd97d1b2 100644 --- a/ui/src/tasks/actions/v2/index.ts +++ b/ui/src/tasks/actions/v2/index.ts @@ -249,9 +249,7 @@ export const deleteTask = (task: Task) => async dispatch => { export const cloneTask = (task: Task, _) => async dispatch => { try { - // const allTaskNames = tasks.map(t => t.name) - // const clonedName = incrementCloneName(allTaskNames, task.name) - await client.tasks.create(task.orgID, task.flux) + await client.tasks.clone(task.id) dispatch(notify(taskCloneSuccess(task.name))) dispatch(populateTasks()) From 5433b1462a69d4019fecc16d23121d24245a2ebd Mon Sep 17 00:00:00 2001 From: Michael Desa Date: Fri, 22 Feb 2019 14:04:01 -0500 Subject: [PATCH 41/54] feat(kv): make user owner of org/dashboard on create --- kv/dashboard.go | 17 ++++++++++++++++- kv/org.go | 19 ++++++++++++++++++- kv/urm.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/kv/dashboard.go b/kv/dashboard.go index f8df5e2b77..7d9a93deb3 100644 --- a/kv/dashboard.go +++ b/kv/dashboard.go @@ -8,6 +8,7 @@ import ( influxdb "github.com/influxdata/influxdb" icontext "github.com/influxdata/influxdb/context" + "go.uber.org/zap" ) var ( @@ -301,7 +302,15 @@ func (s *Service) CreateDashboard(ctx context.Context, d *influxdb.Dashboard) er // TODO(desa): don't populate this here. use the first/last methods of the oplog to get meta fields. d.Meta.CreatedAt = s.time() - return s.putDashboardWithMeta(ctx, tx, d) + if err := s.putDashboardWithMeta(ctx, tx, d); err != nil { + return err + } + + if err := s.addDashboardOwner(ctx, tx, d.ID); err != nil { + s.Logger.Info("failed to make user owner of organization", zap.Error(err)) + } + + return nil }) if err != nil { return &influxdb.Error{ @@ -311,6 +320,12 @@ func (s *Service) CreateDashboard(ctx context.Context, d *influxdb.Dashboard) er return nil } +// addDashboardOwner attempts to create a user resource mapping for the user on the +// authorizer found on context. If no authorizer is found on context if returns an error. +func (s *Service) addDashboardOwner(ctx context.Context, tx Tx, orgID influxdb.ID) error { + return s.addResourceOwner(ctx, tx, influxdb.DashboardsResourceType, orgID) +} + func (s *Service) createCellView(ctx context.Context, tx Tx, dashID, cellID influxdb.ID, view *influxdb.View) error { if view == nil { // If not view exists create the view diff --git a/kv/org.go b/kv/org.go index 70f2579062..33105d4552 100644 --- a/kv/org.go +++ b/kv/org.go @@ -8,6 +8,7 @@ import ( influxdb "github.com/influxdata/influxdb" icontext "github.com/influxdata/influxdb/context" + "go.uber.org/zap" ) var ( @@ -213,10 +214,26 @@ func (s *Service) FindOrganizations(ctx context.Context, filter influxdb.Organiz // CreateOrganization creates a influxdb organization and sets b.ID. func (s *Service) CreateOrganization(ctx context.Context, o *influxdb.Organization) error { return s.kv.Update(func(tx Tx) error { - return s.createOrganization(ctx, tx, o) + if err := s.createOrganization(ctx, tx, o); err != nil { + return err + } + + // Attempt to add user as owner of organization, if that is not possible allow the + // organization to be created anyways. + if err := s.addOrgOwner(ctx, tx, o.ID); err != nil { + s.Logger.Info("failed to make user owner of organization", zap.Error(err)) + } + + return nil }) } +// addOrgOwner attempts to create a user resource mapping for the user on the +// authorizer found on context. If no authorizer is found on context if returns an error. +func (s *Service) addOrgOwner(ctx context.Context, tx Tx, orgID influxdb.ID) error { + return s.addResourceOwner(ctx, tx, influxdb.OrgsResourceType, orgID) +} + func (s *Service) createOrganization(ctx context.Context, tx Tx, o *influxdb.Organization) error { if err := s.uniqueOrganizationName(ctx, tx, o); err != nil { return err diff --git a/kv/urm.go b/kv/urm.go index b4e0e41106..bd11e751a2 100644 --- a/kv/urm.go +++ b/kv/urm.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/influxdata/influxdb" + icontext "github.com/influxdata/influxdb/context" ) var ( @@ -358,3 +359,30 @@ func (s *Service) deleteOrgDependentMappings(ctx context.Context, tx Tx, m *infl return nil } + +func (s *Service) addResourceOwner(ctx context.Context, tx Tx, rt influxdb.ResourceType, id influxdb.ID) error { + a, err := icontext.GetAuthorizer(ctx) + if err != nil { + return &influxdb.Error{ + Code: influxdb.EInternal, + Msg: fmt.Sprintf("could not find authorizer on context when adding user to resource type %s", rt), + } + } + + urm := &influxdb.UserResourceMapping{ + ResourceID: id, + ResourceType: rt, + UserID: a.GetUserID(), + UserType: influxdb.Owner, + } + + if err := s.createUserResourceMapping(ctx, tx, urm); err != nil { + return &influxdb.Error{ + Code: influxdb.EInternal, + Msg: "could not create user resource mapping", + Err: err, + } + } + + return nil +} From 6c96643883301a1d459ba9080af574b8c1df11db Mon Sep 17 00:00:00 2001 From: Mark Rushakoff Date: Wed, 20 Feb 2019 15:49:55 -0800 Subject: [PATCH 42/54] fix(task): pass task's authorization to query system, if using sessions The query system specifically expects an Authorization. When a request comes in using a Session, use the target task's Authorization, if we are allowed to read it, when executing a query against the system bucket. --- http/task_service.go | 121 ++++++++ http/task_service_test.go | 638 +++++++++++++++++++++++++++++++------- 2 files changed, 646 insertions(+), 113 deletions(-) diff --git a/http/task_service.go b/http/task_service.go index 9641a73df7..c3f419e977 100644 --- a/http/task_service.go +++ b/http/task_service.go @@ -769,6 +769,29 @@ func (h *TaskHandler) handleGetLogs(w http.ResponseWriter, r *http.Request) { return } + auth, err := pcontext.GetAuthorizer(ctx) + if err != nil { + err = &platform.Error{ + Err: err, + Code: platform.EUnauthorized, + Msg: "failed to get authorizer", + } + EncodeError(ctx, err, w) + return + } + + if k := auth.Kind(); k != platform.AuthorizationKind { + // Get the authorization for the task, if allowed. + authz, err := h.getAuthorizationForTask(ctx, req.filter.Task) + if err != nil { + EncodeError(ctx, err, w) + return + } + + // We were able to access the authorizer for the task, so reassign that on the context for the rest of this call. + ctx = pcontext.SetAuthorizer(ctx, authz) + } + logs, _, err := h.TaskService.FindLogs(ctx, req.filter) if err != nil { err := &platform.Error{ @@ -834,6 +857,29 @@ func (h *TaskHandler) handleGetRuns(w http.ResponseWriter, r *http.Request) { return } + auth, err := pcontext.GetAuthorizer(ctx) + if err != nil { + err = &platform.Error{ + Err: err, + Code: platform.EUnauthorized, + Msg: "failed to get authorizer", + } + EncodeError(ctx, err, w) + return + } + + if k := auth.Kind(); k != platform.AuthorizationKind { + // Get the authorization for the task, if allowed. + authz, err := h.getAuthorizationForTask(ctx, req.filter.Task) + if err != nil { + EncodeError(ctx, err, w) + return + } + + // We were able to access the authorizer for the task, so reassign that on the context for the rest of this call. + ctx = pcontext.SetAuthorizer(ctx, authz) + } + runs, _, err := h.TaskService.FindRuns(ctx, req.filter) if err != nil { err := &platform.Error{ @@ -1018,6 +1064,29 @@ func (h *TaskHandler) handleGetRun(w http.ResponseWriter, r *http.Request) { return } + auth, err := pcontext.GetAuthorizer(ctx) + if err != nil { + err = &platform.Error{ + Err: err, + Code: platform.EUnauthorized, + Msg: "failed to get authorizer", + } + EncodeError(ctx, err, w) + return + } + + if k := auth.Kind(); k != platform.AuthorizationKind { + // Get the authorization for the task, if allowed. + authz, err := h.getAuthorizationForTask(ctx, req.TaskID) + if err != nil { + EncodeError(ctx, err, w) + return + } + + // We were able to access the authorizer for the task, so reassign that on the context for the rest of this call. + ctx = pcontext.SetAuthorizer(ctx, authz) + } + run, err := h.TaskService.FindRunByID(ctx, req.TaskID, req.RunID) if err != nil { err := &platform.Error{ @@ -1152,6 +1221,29 @@ func (h *TaskHandler) handleRetryRun(w http.ResponseWriter, r *http.Request) { return } + auth, err := pcontext.GetAuthorizer(ctx) + if err != nil { + err = &platform.Error{ + Err: err, + Code: platform.EUnauthorized, + Msg: "failed to get authorizer", + } + EncodeError(ctx, err, w) + return + } + + if k := auth.Kind(); k != platform.AuthorizationKind { + // Get the authorization for the task, if allowed. + authz, err := h.getAuthorizationForTask(ctx, req.TaskID) + if err != nil { + EncodeError(ctx, err, w) + return + } + + // We were able to access the authorizer for the task, so reassign that on the context for the rest of this call. + ctx = pcontext.SetAuthorizer(ctx, authz) + } + run, err := h.TaskService.RetryRun(ctx, req.TaskID, req.RunID) if err != nil { err := &platform.Error{ @@ -1230,6 +1322,35 @@ func (h *TaskHandler) populateTaskCreateOrg(ctx context.Context, tc *platform.Ta return nil } +// getAuthorizationForTask looks up the authorization associated with taskID, +// ensuring that the authorizer on ctx is allowed to view the task and the authorization. +// +// This method returns a *platform.Error, suitable for directly passing to EncodeError. +func (h *TaskHandler) getAuthorizationForTask(ctx context.Context, taskID platform.ID) (*platform.Authorization, *platform.Error) { + // First look up the task, if we're allowed. + // This assumes h.TaskService validates access. + t, err := h.TaskService.FindTaskByID(ctx, taskID) + if err != nil { + return nil, &platform.Error{ + Err: err, + Code: platform.EUnauthorized, + Msg: "task ID unknown or unauthorized", + } + } + + // Explicitly check against an authorized authorization service. + authz, err := authorizer.NewAuthorizationService(h.AuthorizationService).FindAuthorizationByID(ctx, t.AuthorizationID) + if err != nil { + return nil, &platform.Error{ + Err: err, + Code: platform.EUnauthorized, + Msg: "unable to access task authorization", + } + } + + return authz, nil +} + // TaskService connects to Influx via HTTP using tokens to manage tasks. type TaskService struct { Addr string diff --git a/http/task_service_test.go b/http/task_service_test.go index 5778f8a2ef..d3d39f38f5 100644 --- a/http/task_service_test.go +++ b/http/task_service_test.go @@ -377,7 +377,7 @@ func TestTaskHandler_handleGetRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { r := httptest.NewRequest("GET", "http://any.url", nil) r = r.WithContext(context.WithValue( - context.TODO(), + context.Background(), httprouter.ParamsKey, httprouter.Params{ { @@ -389,6 +389,7 @@ func TestTaskHandler_handleGetRun(t *testing.T) { Value: tt.args.runID.String(), }, })) + r = r.WithContext(pcontext.SetAuthorizer(r.Context(), &platform.Authorization{Permissions: platform.OperPermissions()})) w := httptest.NewRecorder() taskBackend := NewMockTaskBackend(t) taskBackend.TaskService = tt.fields.taskService @@ -490,7 +491,7 @@ func TestTaskHandler_handleGetRuns(t *testing.T) { t.Run(tt.name, func(t *testing.T) { r := httptest.NewRequest("GET", "http://any.url", nil) r = r.WithContext(context.WithValue( - context.TODO(), + context.Background(), httprouter.ParamsKey, httprouter.Params{ { @@ -498,6 +499,7 @@ func TestTaskHandler_handleGetRuns(t *testing.T) { Value: tt.args.taskID.String(), }, })) + r = r.WithContext(pcontext.SetAuthorizer(r.Context(), &platform.Authorization{Permissions: platform.OperPermissions()})) w := httptest.NewRecorder() taskBackend := NewMockTaskBackend(t) taskBackend.TaskService = tt.fields.taskService @@ -538,6 +540,9 @@ func TestTaskHandler_NotFoundStatus(t *testing.T) { t.Fatal(err) } + // Create a session to associate with the contexts, so authorization checks pass. + authz := &platform.Authorization{Permissions: platform.OperPermissions()} + const taskID, runID = platform.ID(0xCCCCCC), platform.ID(0xAAAAAA) var ( @@ -763,7 +768,9 @@ func TestTaskHandler_NotFoundStatus(t *testing.T) { okPath := fmt.Sprintf(tc.pathFmt, tc.okPathArgs...) t.Run("matching ID: "+tc.method+" "+okPath, func(t *testing.T) { w := httptest.NewRecorder() - r := httptest.NewRequest(tc.method, "http://task.example/api/v2"+okPath, strings.NewReader(tc.body)) + r := httptest.NewRequest(tc.method, "http://task.example/api/v2"+okPath, strings.NewReader(tc.body)).WithContext( + pcontext.SetAuthorizer(context.Background(), authz), + ) h.ServeHTTP(w, r) @@ -782,7 +789,9 @@ func TestTaskHandler_NotFoundStatus(t *testing.T) { path := fmt.Sprintf(tc.pathFmt, nfa...) t.Run(tc.method+" "+path, func(t *testing.T) { w := httptest.NewRecorder() - r := httptest.NewRequest(tc.method, "http://task.example/api/v2"+path, strings.NewReader(tc.body)) + r := httptest.NewRequest(tc.method, "http://task.example/api/v2"+path, strings.NewReader(tc.body)).WithContext( + pcontext.SetAuthorizer(context.Background(), authz), + ) h.ServeHTTP(w, r) @@ -899,40 +908,10 @@ func TestService_handlePostTaskLabel(t *testing.T) { } } -func TestTaskHandler_CreateTaskFromSession(t *testing.T) { +func TestTaskHandler_Sessions(t *testing.T) { + // Common setup to get a working base for using tasks. i := inmem.NewService() - taskID := platform.ID(9) - var createdTasks []platform.TaskCreate - ts := &mock.TaskService{ - CreateTaskFn: func(_ context.Context, tc platform.TaskCreate) (*platform.Task, error) { - createdTasks = append(createdTasks, tc) - // Task with fake IDs so it can be serialized. - return &platform.Task{ID: taskID, OrganizationID: 99, AuthorizationID: 999, Name: "x"}, nil - }, - // Needed due to task authorization bootstrapping. - UpdateTaskFn: func(ctx context.Context, id platform.ID, tu platform.TaskUpdate) (*platform.Task, error) { - authz, err := i.FindAuthorizationByToken(ctx, tu.Token) - if err != nil { - t.Fatal(err) - } - - return &platform.Task{ID: taskID, OrganizationID: 99, AuthorizationID: authz.ID, Name: "x"}, nil - }, - } - - h := NewTaskHandler(&TaskBackend{ - Logger: zaptest.NewLogger(t), - - TaskService: ts, - AuthorizationService: i, - OrganizationService: i, - UserResourceMappingService: i, - LabelService: i, - UserService: i, - BucketService: i, - }) - ctx := context.Background() // Set up user and org. @@ -965,95 +944,528 @@ func TestTaskHandler_CreateTaskFromSession(t *testing.T) { t.Fatal(err) } - // Create a session for use in authorizing context. - s := &platform.Session{ + sessionAllPermsCtx := pcontext.SetAuthorizer(context.Background(), &platform.Session{ UserID: u.ID, Permissions: platform.OperPermissions(), ExpiresAt: time.Now().Add(24 * time.Hour), - } - - b, err := json.Marshal(platform.TaskCreate{ - Flux: `option task = {name:"x", every:1m} from(bucket:"b-src") |> range(start:-1m) |> to(bucket:"b-dst", org:"o")`, - OrganizationID: o.ID, }) - if err != nil { - t.Fatal(err) + sessionNoPermsCtx := pcontext.SetAuthorizer(context.Background(), &platform.Session{ + UserID: u.ID, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + + newHandler := func(t *testing.T, ts *mock.TaskService) *TaskHandler { + return NewTaskHandler(&TaskBackend{ + Logger: zaptest.NewLogger(t), + + TaskService: ts, + AuthorizationService: i, + OrganizationService: i, + UserResourceMappingService: i, + LabelService: i, + UserService: i, + BucketService: i, + }) } - sessionCtx := pcontext.SetAuthorizer(context.Background(), s) - url := fmt.Sprintf("http://localhost:9999/api/v2/tasks") - r := httptest.NewRequest("POST", url, bytes.NewReader(b)).WithContext(sessionCtx) + t.Run("creating a task from a session", func(t *testing.T) { + taskID := platform.ID(9) + var createdTasks []platform.TaskCreate + ts := &mock.TaskService{ + CreateTaskFn: func(_ context.Context, tc platform.TaskCreate) (*platform.Task, error) { + createdTasks = append(createdTasks, tc) + // Task with fake IDs so it can be serialized. + return &platform.Task{ID: taskID, OrganizationID: 99, AuthorizationID: 999, Name: "x"}, nil + }, + // Needed due to task authorization bootstrapping. + UpdateTaskFn: func(ctx context.Context, id platform.ID, tu platform.TaskUpdate) (*platform.Task, error) { + authz, err := i.FindAuthorizationByToken(ctx, tu.Token) + if err != nil { + t.Fatal(err) + } - w := httptest.NewRecorder() + return &platform.Task{ID: taskID, OrganizationID: 99, AuthorizationID: authz.ID, Name: "x"}, nil + }, + } - h.handlePostTask(w, r) + h := newHandler(t, ts) + url := "http://localhost:9999/api/v2/tasks" - res := w.Result() - body, err := ioutil.ReadAll(res.Body) - if err != nil { - t.Fatal(err) - } - if res.StatusCode != http.StatusCreated { - t.Logf("response body: %s", body) - t.Fatalf("expected status created, got %v", res.StatusCode) - } + b, err := json.Marshal(platform.TaskCreate{ + Flux: `option task = {name:"x", every:1m} from(bucket:"b-src") |> range(start:-1m) |> to(bucket:"b-dst", org:"o")`, + OrganizationID: o.ID, + }) + if err != nil { + t.Fatal(err) + } - if len(createdTasks) != 1 { - t.Fatalf("didn't create task; got %#v", createdTasks) - } + r := httptest.NewRequest("POST", url, bytes.NewReader(b)).WithContext(sessionAllPermsCtx) + w := httptest.NewRecorder() - // The task should have been created with a valid token. - var createdTask platform.Task - if err := json.Unmarshal([]byte(body), &createdTask); err != nil { - t.Fatal(err) - } - authz, err := i.FindAuthorizationByID(ctx, createdTask.AuthorizationID) - if err != nil { - t.Fatal(err) - } - if authz.UserID != u.ID { - t.Fatalf("expected authorization to be associated with user %v, got %v", u.ID, authz.UserID) - } - if authz.OrgID != o.ID { - t.Fatalf("expected authorization to be associated with org %v, got %v", o.ID, authz.OrgID) - } - const expDesc = `auto-generated authorization for task "x"` - if authz.Description != expDesc { - t.Fatalf("expected authorization to be created with description %q, got %q", expDesc, authz.Description) - } + h.handlePostTask(w, r) - // The authorization should be allowed to read and write the target buckets, - // and it should be allowed to read its task. - if !authz.Allowed(platform.Permission{ - Action: platform.ReadAction, - Resource: platform.Resource{ - Type: platform.BucketsResourceType, - OrgID: &o.ID, - ID: &bSrc.ID, - }, - }) { - t.Logf("WARNING: permissions on `from` buckets not yet accessible: update test after https://github.com/influxdata/flux/issues/114 is fixed.") - } + res := w.Result() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusCreated { + t.Logf("response body: %s", body) + t.Fatalf("expected status created, got %v", res.StatusCode) + } - if !authz.Allowed(platform.Permission{ - Action: platform.WriteAction, - Resource: platform.Resource{ - Type: platform.BucketsResourceType, - OrgID: &o.ID, - ID: &bDst.ID, - }, - }) { - t.Fatalf("expected authorization to be allowed write access to destination bucket, but it wasn't allowed") - } + if len(createdTasks) != 1 { + t.Fatalf("didn't create task; got %#v", createdTasks) + } - if !authz.Allowed(platform.Permission{ - Action: platform.ReadAction, - Resource: platform.Resource{ - Type: platform.TasksResourceType, - OrgID: &o.ID, - ID: &taskID, - }, - }) { - t.Fatalf("expected authorization to be allowed to read its task, but it wasn't allowed") - } + // The task should have been created with a valid token. + var createdTask platform.Task + if err := json.Unmarshal([]byte(body), &createdTask); err != nil { + t.Fatal(err) + } + authz, err := i.FindAuthorizationByID(ctx, createdTask.AuthorizationID) + if err != nil { + t.Fatal(err) + } + + if authz.OrgID != o.ID { + t.Fatalf("expected authorization to have org ID %v, got %v", o.ID, authz.OrgID) + } + if authz.UserID != u.ID { + t.Fatalf("expected authorization to have user ID %v, got %v", u.ID, authz.UserID) + } + + const expDesc = `auto-generated authorization for task "x"` + if authz.Description != expDesc { + t.Fatalf("expected authorization to be created with description %q, got %q", expDesc, authz.Description) + } + + // The authorization should be allowed to read and write the target buckets, + // and it should be allowed to read its task. + if !authz.Allowed(platform.Permission{ + Action: platform.ReadAction, + Resource: platform.Resource{ + Type: platform.BucketsResourceType, + OrgID: &o.ID, + ID: &bSrc.ID, + }, + }) { + t.Logf("WARNING: permissions on `from` buckets not yet accessible: update test after https://github.com/influxdata/flux/issues/114 is fixed.") + } + + if !authz.Allowed(platform.Permission{ + Action: platform.WriteAction, + Resource: platform.Resource{ + Type: platform.BucketsResourceType, + OrgID: &o.ID, + ID: &bDst.ID, + }, + }) { + t.Fatalf("expected authorization to be allowed write access to destination bucket, but it wasn't allowed") + } + + if !authz.Allowed(platform.Permission{ + Action: platform.ReadAction, + Resource: platform.Resource{ + Type: platform.TasksResourceType, + OrgID: &o.ID, + ID: &taskID, + }, + }) { + t.Fatalf("expected authorization to be allowed to read its task, but it wasn't allowed") + } + + // Session without permissions should not be allowed to create task. + r = httptest.NewRequest("POST", url, bytes.NewReader(b)).WithContext(sessionNoPermsCtx) + w = httptest.NewRecorder() + + h.handlePostTask(w, r) + + res = w.Result() + body, err = ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized && res.StatusCode != http.StatusForbidden { + t.Logf("response body: %s", body) + t.Fatalf("expected status unauthorized or forbidden, got %v", res.StatusCode) + } + }) + + t.Run("get runs for a task", func(t *testing.T) { + // Unique authorization to associate with our fake task. + taskAuth := &platform.Authorization{OrgID: o.ID, UserID: u.ID} + if err := i.CreateAuthorization(ctx, taskAuth); err != nil { + t.Fatal(err) + } + + const taskID = platform.ID(12345) + const runID = platform.ID(9876) + + var findRunsCtx context.Context + ts := &mock.TaskService{ + FindRunsFn: func(ctx context.Context, f platform.RunFilter) ([]*platform.Run, int, error) { + findRunsCtx = ctx + if f.Task != taskID { + t.Fatalf("expected task ID %v, got %v", taskID, f.Task) + } + + return []*platform.Run{ + {ID: runID, TaskID: taskID}, + }, 1, nil + }, + + FindTaskByIDFn: func(ctx context.Context, id platform.ID) (*platform.Task, error) { + if id != taskID { + return nil, backend.ErrTaskNotFound + } + + return &platform.Task{ + ID: taskID, + OrganizationID: o.ID, + AuthorizationID: taskAuth.ID, + }, nil + }, + } + + h := newHandler(t, ts) + url := fmt.Sprintf("http://localhost:9999/api/v2/tasks/%s/runs", taskID) + valCtx := context.WithValue(sessionAllPermsCtx, httprouter.ParamsKey, httprouter.Params{{Key: "id", Value: taskID.String()}}) + r := httptest.NewRequest("GET", url, nil).WithContext(valCtx) + w := httptest.NewRecorder() + h.handleGetRuns(w, r) + + res := w.Result() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Logf("response body: %s", body) + t.Fatalf("expected status OK, got %v", res.StatusCode) + } + + // The context passed to TaskService.FindRuns must be a valid authorization (not a session). + authr, err := pcontext.GetAuthorizer(findRunsCtx) + if err != nil { + t.Fatal(err) + } + if authr.Kind() != platform.AuthorizationKind { + t.Fatalf("expected context's authorizer to be of kind %q, got %q", platform.AuthorizationKind, authr.Kind()) + } + if authr.Identifier() != taskAuth.ID { + t.Fatalf("expected context's authorizer ID to be %v, got %v", taskAuth.ID, authr.Identifier()) + } + + // Other user without permissions on the task or authorization should be disallowed. + otherUser := &platform.User{Name: "other-" + t.Name()} + if err := i.CreateUser(ctx, otherUser); err != nil { + t.Fatal(err) + } + + valCtx = pcontext.SetAuthorizer(valCtx, &platform.Session{ + UserID: otherUser.ID, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + + r = httptest.NewRequest("GET", url, nil).WithContext(valCtx) + w = httptest.NewRecorder() + h.handleGetRuns(w, r) + + res = w.Result() + body, err = ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Logf("response body: %s", body) + t.Fatalf("expected status unauthorized, got %v", res.StatusCode) + } + }) + + t.Run("get single run for a task", func(t *testing.T) { + // Unique authorization to associate with our fake task. + taskAuth := &platform.Authorization{OrgID: o.ID, UserID: u.ID} + if err := i.CreateAuthorization(ctx, taskAuth); err != nil { + t.Fatal(err) + } + + const taskID = platform.ID(12345) + const runID = platform.ID(9876) + + var findRunByIDCtx context.Context + ts := &mock.TaskService{ + FindRunByIDFn: func(ctx context.Context, tid, rid platform.ID) (*platform.Run, error) { + findRunByIDCtx = ctx + if tid != taskID { + t.Fatalf("expected task ID %v, got %v", taskID, tid) + } + if rid != runID { + t.Fatalf("expected run ID %v, got %v", runID, rid) + } + + return &platform.Run{ID: runID, TaskID: taskID}, nil + }, + + FindTaskByIDFn: func(ctx context.Context, id platform.ID) (*platform.Task, error) { + if id != taskID { + return nil, backend.ErrTaskNotFound + } + + return &platform.Task{ + ID: taskID, + OrganizationID: o.ID, + AuthorizationID: taskAuth.ID, + }, nil + }, + } + + h := newHandler(t, ts) + url := fmt.Sprintf("http://localhost:9999/api/v2/tasks/%s/runs/%s", taskID, runID) + valCtx := context.WithValue(sessionAllPermsCtx, httprouter.ParamsKey, httprouter.Params{ + {Key: "id", Value: taskID.String()}, + {Key: "rid", Value: runID.String()}, + }) + r := httptest.NewRequest("GET", url, nil).WithContext(valCtx) + w := httptest.NewRecorder() + h.handleGetRun(w, r) + + res := w.Result() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Logf("response body: %s", body) + t.Fatalf("expected status OK, got %v", res.StatusCode) + } + + // The context passed to TaskService.FindRunByID must be a valid authorization (not a session). + authr, err := pcontext.GetAuthorizer(findRunByIDCtx) + if err != nil { + t.Fatal(err) + } + if authr.Kind() != platform.AuthorizationKind { + t.Fatalf("expected context's authorizer to be of kind %q, got %q", platform.AuthorizationKind, authr.Kind()) + } + if authr.Identifier() != taskAuth.ID { + t.Fatalf("expected context's authorizer ID to be %v, got %v", taskAuth.ID, authr.Identifier()) + } + + // Other user without permissions on the task or authorization should be disallowed. + otherUser := &platform.User{Name: "other-" + t.Name()} + if err := i.CreateUser(ctx, otherUser); err != nil { + t.Fatal(err) + } + + valCtx = pcontext.SetAuthorizer(valCtx, &platform.Session{ + UserID: otherUser.ID, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + + r = httptest.NewRequest("GET", url, nil).WithContext(valCtx) + w = httptest.NewRecorder() + h.handleGetRuns(w, r) + + res = w.Result() + body, err = ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Logf("response body: %s", body) + t.Fatalf("expected status unauthorized, got %v", res.StatusCode) + } + }) + + t.Run("get logs for a run", func(t *testing.T) { + // Unique authorization to associate with our fake task. + taskAuth := &platform.Authorization{OrgID: o.ID, UserID: u.ID} + if err := i.CreateAuthorization(ctx, taskAuth); err != nil { + t.Fatal(err) + } + + const taskID = platform.ID(12345) + const runID = platform.ID(9876) + + var findLogsCtx context.Context + ts := &mock.TaskService{ + FindLogsFn: func(ctx context.Context, f platform.LogFilter) ([]*platform.Log, int, error) { + findLogsCtx = ctx + if f.Task != taskID { + t.Fatalf("expected task ID %v, got %v", taskID, f.Task) + } + if *f.Run != runID { + t.Fatalf("expected run ID %v, got %v", runID, *f.Run) + } + + line := platform.Log("a log line") + return []*platform.Log{&line}, 1, nil + }, + + FindTaskByIDFn: func(ctx context.Context, id platform.ID) (*platform.Task, error) { + if id != taskID { + return nil, backend.ErrTaskNotFound + } + + return &platform.Task{ + ID: taskID, + OrganizationID: o.ID, + AuthorizationID: taskAuth.ID, + }, nil + }, + } + + h := newHandler(t, ts) + url := fmt.Sprintf("http://localhost:9999/api/v2/tasks/%s/runs/%s/logs", taskID, runID) + valCtx := context.WithValue(sessionAllPermsCtx, httprouter.ParamsKey, httprouter.Params{ + {Key: "id", Value: taskID.String()}, + {Key: "rid", Value: runID.String()}, + }) + r := httptest.NewRequest("GET", url, nil).WithContext(valCtx) + w := httptest.NewRecorder() + h.handleGetLogs(w, r) + + res := w.Result() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Logf("response body: %s", body) + t.Fatalf("expected status OK, got %v", res.StatusCode) + } + + // The context passed to TaskService.FindLogs must be a valid authorization (not a session). + authr, err := pcontext.GetAuthorizer(findLogsCtx) + if err != nil { + t.Fatal(err) + } + if authr.Kind() != platform.AuthorizationKind { + t.Fatalf("expected context's authorizer to be of kind %q, got %q", platform.AuthorizationKind, authr.Kind()) + } + if authr.Identifier() != taskAuth.ID { + t.Fatalf("expected context's authorizer ID to be %v, got %v", taskAuth.ID, authr.Identifier()) + } + + // Other user without permissions on the task or authorization should be disallowed. + otherUser := &platform.User{Name: "other-" + t.Name()} + if err := i.CreateUser(ctx, otherUser); err != nil { + t.Fatal(err) + } + + valCtx = pcontext.SetAuthorizer(valCtx, &platform.Session{ + UserID: otherUser.ID, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + + r = httptest.NewRequest("GET", url, nil).WithContext(valCtx) + w = httptest.NewRecorder() + h.handleGetRuns(w, r) + + res = w.Result() + body, err = ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Logf("response body: %s", body) + t.Fatalf("expected status unauthorized, got %v", res.StatusCode) + } + }) + + t.Run("retry a run", func(t *testing.T) { + // Unique authorization to associate with our fake task. + taskAuth := &platform.Authorization{OrgID: o.ID, UserID: u.ID} + if err := i.CreateAuthorization(ctx, taskAuth); err != nil { + t.Fatal(err) + } + + const taskID = platform.ID(12345) + const runID = platform.ID(9876) + + var retryRunCtx context.Context + ts := &mock.TaskService{ + RetryRunFn: func(ctx context.Context, tid, rid platform.ID) (*platform.Run, error) { + retryRunCtx = ctx + if tid != taskID { + t.Fatalf("expected task ID %v, got %v", taskID, tid) + } + if rid != runID { + t.Fatalf("expected run ID %v, got %v", runID, rid) + } + + return &platform.Run{ID: 10 * runID, TaskID: taskID}, nil + }, + + FindTaskByIDFn: func(ctx context.Context, id platform.ID) (*platform.Task, error) { + if id != taskID { + return nil, backend.ErrTaskNotFound + } + + return &platform.Task{ + ID: taskID, + OrganizationID: o.ID, + AuthorizationID: taskAuth.ID, + }, nil + }, + } + + h := newHandler(t, ts) + url := fmt.Sprintf("http://localhost:9999/api/v2/tasks/%s/runs/%s/retry", taskID, runID) + valCtx := context.WithValue(sessionAllPermsCtx, httprouter.ParamsKey, httprouter.Params{ + {Key: "id", Value: taskID.String()}, + {Key: "rid", Value: runID.String()}, + }) + r := httptest.NewRequest("POST", url, nil).WithContext(valCtx) + w := httptest.NewRecorder() + h.handleRetryRun(w, r) + + res := w.Result() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Logf("response body: %s", body) + t.Fatalf("expected status OK, got %v", res.StatusCode) + } + + // The context passed to TaskService.RetryRun must be a valid authorization (not a session). + authr, err := pcontext.GetAuthorizer(retryRunCtx) + if err != nil { + t.Fatal(err) + } + if authr.Kind() != platform.AuthorizationKind { + t.Fatalf("expected context's authorizer to be of kind %q, got %q", platform.AuthorizationKind, authr.Kind()) + } + if authr.Identifier() != taskAuth.ID { + t.Fatalf("expected context's authorizer ID to be %v, got %v", taskAuth.ID, authr.Identifier()) + } + + // Other user without permissions on the task or authorization should be disallowed. + otherUser := &platform.User{Name: "other-" + t.Name()} + if err := i.CreateUser(ctx, otherUser); err != nil { + t.Fatal(err) + } + + valCtx = pcontext.SetAuthorizer(valCtx, &platform.Session{ + UserID: otherUser.ID, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + + r = httptest.NewRequest("POST", url, nil).WithContext(valCtx) + w = httptest.NewRecorder() + h.handleGetRuns(w, r) + + res = w.Result() + body, err = ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Logf("response body: %s", body) + t.Fatalf("expected status unauthorized, got %v", res.StatusCode) + } + }) } From 385c0ae4d14f4fb7bb6c4c0f1fd59f17dc9d093a Mon Sep 17 00:00:00 2001 From: Alirie Gray Date: Wed, 20 Feb 2019 13:07:04 -0800 Subject: [PATCH 43/54] Add menu for Variables to Time Machine script editor --- ui/src/timeMachine/components/SearchBar.scss | 8 +++ .../SearchBar.tsx | 8 ++- .../components/TimeMachineFluxEditor.scss | 12 +++- .../components/TimeMachineFluxEditor.tsx | 57 +++++++++++++++- ui/src/timeMachine/components/ToolbarTab.scss | 31 +++++++++ ui/src/timeMachine/components/ToolbarTab.tsx | 26 ++++++++ .../FluxFunctionsToolbar.scss | 8 --- .../FluxFunctionsToolbar.tsx | 10 +-- .../FunctionCategory.tsx | 2 +- .../FunctionTooltip.tsx | 8 +-- .../ToolbarFunction.tsx | 2 +- .../TooltipArguments.tsx | 0 .../TooltipDescription.tsx | 0 .../TooltipExample.tsx | 0 .../TooltipLink.tsx | 0 .../TransformToolbarFunctions.tsx | 0 .../variableToolbar/FetchVariables.tsx | 48 ++++++++++++++ .../variableToolbar/VariableItem.tsx | 25 +++++++ .../variableToolbar/VariableToolbar.scss | 42 ++++++++++++ .../variableToolbar/VariableToolbar.tsx | 65 +++++++++++++++++++ 20 files changed, 327 insertions(+), 25 deletions(-) create mode 100644 ui/src/timeMachine/components/SearchBar.scss rename ui/src/timeMachine/components/{flux_functions_toolbar => }/SearchBar.tsx (85%) create mode 100644 ui/src/timeMachine/components/ToolbarTab.scss create mode 100644 ui/src/timeMachine/components/ToolbarTab.tsx rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/FluxFunctionsToolbar.scss (93%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/FluxFunctionsToolbar.tsx (86%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/FunctionCategory.tsx (87%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/FunctionTooltip.tsx (87%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/ToolbarFunction.tsx (95%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/TooltipArguments.tsx (100%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/TooltipDescription.tsx (100%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/TooltipExample.tsx (100%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/TooltipLink.tsx (100%) rename ui/src/timeMachine/components/{flux_functions_toolbar => fluxFunctionsToolbar}/TransformToolbarFunctions.tsx (100%) create mode 100644 ui/src/timeMachine/components/variableToolbar/FetchVariables.tsx create mode 100644 ui/src/timeMachine/components/variableToolbar/VariableItem.tsx create mode 100644 ui/src/timeMachine/components/variableToolbar/VariableToolbar.scss create mode 100644 ui/src/timeMachine/components/variableToolbar/VariableToolbar.tsx diff --git a/ui/src/timeMachine/components/SearchBar.scss b/ui/src/timeMachine/components/SearchBar.scss new file mode 100644 index 0000000000..bf99fa2962 --- /dev/null +++ b/ui/src/timeMachine/components/SearchBar.scss @@ -0,0 +1,8 @@ +@import "src/style/modules"; + +.search-bar { + padding: $ix-marg-b; + flex-shrink: 0; + border-bottom: $ix-border solid $g4-onyx; + background-color: $g3-castle; + } \ No newline at end of file diff --git a/ui/src/timeMachine/components/flux_functions_toolbar/SearchBar.tsx b/ui/src/timeMachine/components/SearchBar.tsx similarity index 85% rename from ui/src/timeMachine/components/flux_functions_toolbar/SearchBar.tsx rename to ui/src/timeMachine/components/SearchBar.tsx index 9989a78e04..f7b8accbc0 100644 --- a/ui/src/timeMachine/components/flux_functions_toolbar/SearchBar.tsx +++ b/ui/src/timeMachine/components/SearchBar.tsx @@ -8,8 +8,12 @@ import {Input, IconFont} from 'src/clockface' // Types import {InputType} from 'src/clockface/components/inputs/Input' +// Styles +import 'src/timeMachine/components/SearchBar.scss' + interface Props { onSearch: (s: string) => void + resourceName: string } interface State { @@ -31,11 +35,11 @@ class SearchBar extends PureComponent { public render() { return ( -
+
diff --git a/ui/src/timeMachine/components/TimeMachineFluxEditor.scss b/ui/src/timeMachine/components/TimeMachineFluxEditor.scss index b1eabb0e04..4704b9b548 100644 --- a/ui/src/timeMachine/components/TimeMachineFluxEditor.scss +++ b/ui/src/timeMachine/components/TimeMachineFluxEditor.scss @@ -1,4 +1,14 @@ -@import "src/style/modules"; +@import 'src/style/modules'; + +.toolbar-tab-container { + width: 100%; + display: inline-flex; + align-items: stretch; + height: 38px; + background-color: $g2-kevlar; + padding: $ix-marg-b; + padding-bottom: 0; +} .time-machine-flux-editor { position: absolute; diff --git a/ui/src/timeMachine/components/TimeMachineFluxEditor.tsx b/ui/src/timeMachine/components/TimeMachineFluxEditor.tsx index b97175a59e..924cd66235 100644 --- a/ui/src/timeMachine/components/TimeMachineFluxEditor.tsx +++ b/ui/src/timeMachine/components/TimeMachineFluxEditor.tsx @@ -5,7 +5,9 @@ import {connect} from 'react-redux' // Components import FluxEditor from 'src/shared/components/FluxEditor' import Threesizer from 'src/shared/components/threesizer/Threesizer' -import FluxFunctionsToolbar from 'src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar' +import FluxFunctionsToolbar from 'src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar' +import VariablesToolbar from 'src/timeMachine/components/variableToolbar/VariableToolbar' +import ToolbarTab from 'src/timeMachine/components/ToolbarTab' // Actions import {setActiveQueryText, submitScript} from 'src/timeMachine/actions' @@ -19,6 +21,7 @@ import {HANDLE_VERTICAL, HANDLE_NONE} from 'src/shared/constants' // Types import {AppState} from 'src/types/v2' +// Styles import 'src/timeMachine/components/TimeMachineFluxEditor.scss' interface StateProps { @@ -30,9 +33,21 @@ interface DispatchProps { onSubmitScript: typeof submitScript } +interface State { + displayFluxFunctions: boolean +} + type Props = StateProps & DispatchProps -class TimeMachineFluxEditor extends PureComponent { +class TimeMachineFluxEditor extends PureComponent { + constructor(props) { + super(props) + + this.state = { + displayFluxFunctions: false, + } + } + public render() { const {activeQueryText, onSetActiveQueryText, onSubmitScript} = this.props @@ -51,7 +66,25 @@ class TimeMachineFluxEditor extends PureComponent { ), }, { - render: () => , + render: () => { + return ( + <> +
+ + +
+ {this.rightDivision} + + ) + }, handlePixels: 6, size: 0.25, }, @@ -63,6 +96,24 @@ class TimeMachineFluxEditor extends PureComponent {
) } + + private get rightDivision(): JSX.Element { + const {displayFluxFunctions} = this.state + + if (displayFluxFunctions) { + return + } + + return + } + + private showFluxFunctions = () => { + this.setState({displayFluxFunctions: true}) + } + + private hideFluxFunctions = () => { + this.setState({displayFluxFunctions: false}) + } } const mstp = (state: AppState) => { diff --git a/ui/src/timeMachine/components/ToolbarTab.scss b/ui/src/timeMachine/components/ToolbarTab.scss new file mode 100644 index 0000000000..9f379db3f2 --- /dev/null +++ b/ui/src/timeMachine/components/ToolbarTab.scss @@ -0,0 +1,31 @@ +@import 'src/style/modules'; + +.toolbar-tab { + background: rgba($g3-castle, 0.5); + margin-right: $ix-border; + border-radius: $ix-radius $ix-radius 0 0; + display: flex; + align-items: center; + color: $g10-wolf; + font-weight: 700; + padding: 0 ($ix-marg-c - $ix-marg-a); + font-size: $ix-text-tiny; + user-select: none; + white-space: nowrap; + transition: color 0.25s ease, background-color 0.25s ease; + + &:last-child { + margin-right: 0; + } + + &.active { + flex: 0 0 auto; + background: $g3-castle; + color: $g16-pearl; + } + + &:hover { + color: $g16-pearl; + cursor: pointer; + } +} diff --git a/ui/src/timeMachine/components/ToolbarTab.tsx b/ui/src/timeMachine/components/ToolbarTab.tsx new file mode 100644 index 0000000000..9be445864c --- /dev/null +++ b/ui/src/timeMachine/components/ToolbarTab.tsx @@ -0,0 +1,26 @@ +// Libraries +import React, {PureComponent} from 'react' + +// Styles +import 'src/timeMachine/components/ToolbarTab.scss' + +interface Props { + onSetActive: () => void + name: string + active: boolean +} + +export default class ToolbarTab extends PureComponent { + public render() { + const {active, onSetActive, name} = this.props + return ( +
+ {name} +
+ ) + } +} diff --git a/ui/src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.scss b/ui/src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.scss similarity index 93% rename from ui/src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.scss rename to ui/src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.scss index 4a2f2c1772..e825580f08 100644 --- a/ui/src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.scss +++ b/ui/src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.scss @@ -8,14 +8,6 @@ font-size: 13px; } -.flux-functions-toolbar--search { - padding: $ix-marg-a; - flex-shrink: 0; - padding-bottom: $ix-marg-a + 1px; - border-bottom: $ix-border solid $g4-onyx; - background-color: $g3-castle; -} - .flux-functions-toolbar--list { padding-bottom: $ix-marg-a; } diff --git a/ui/src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.tsx b/ui/src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.tsx similarity index 86% rename from ui/src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.tsx rename to ui/src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.tsx index dc50935331..f32dcaa3c2 100644 --- a/ui/src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.tsx +++ b/ui/src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.tsx @@ -3,9 +3,9 @@ import React, {PureComponent} from 'react' import {connect} from 'react-redux' // Components -import TransformToolbarFunctions from 'src/timeMachine/components/flux_functions_toolbar/TransformToolbarFunctions' -import FunctionCategory from 'src/timeMachine/components/flux_functions_toolbar/FunctionCategory' -import SearchBar from 'src/timeMachine/components/flux_functions_toolbar/SearchBar' +import TransformToolbarFunctions from 'src/timeMachine/components/fluxFunctionsToolbar/TransformToolbarFunctions' +import FunctionCategory from 'src/timeMachine/components/fluxFunctionsToolbar/FunctionCategory' +import SearchBar from 'src/timeMachine/components/SearchBar' import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar' import {ErrorHandling} from 'src/shared/decorators/errors' @@ -19,7 +19,7 @@ import {getActiveQuery} from 'src/timeMachine/selectors' import {FLUX_FUNCTIONS} from 'src/shared/constants/fluxFunctions' // Styles -import 'src/timeMachine/components/flux_functions_toolbar/FluxFunctionsToolbar.scss' +import 'src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.scss' // Types import {AppState} from 'src/types/v2' @@ -46,7 +46,7 @@ class FluxFunctionsToolbar extends PureComponent { return (
- +
JSX.Element +} + +interface State { + variables: Variable[] + loading: RemoteDataState +} + +class FetchVariables extends PureComponent { + public state: State = { + variables: [], + loading: RemoteDataState.NotStarted, + } + + public async componentDidMount() { + this.fetchVariables() + } + + public render() { + const {variables, loading} = this.state + + return this.props.children(variables, loading) + } + + public fetchVariables = async () => { + this.setState({loading: RemoteDataState.Loading}) + + const variables = await client.variables.getAll() + this.setState({ + variables: _.sortBy(variables, ['name']), + loading: RemoteDataState.Done, + }) + } +} + +export default FetchVariables diff --git a/ui/src/timeMachine/components/variableToolbar/VariableItem.tsx b/ui/src/timeMachine/components/variableToolbar/VariableItem.tsx new file mode 100644 index 0000000000..cd97cb49a4 --- /dev/null +++ b/ui/src/timeMachine/components/variableToolbar/VariableItem.tsx @@ -0,0 +1,25 @@ +// Libraries +import React, {PureComponent} from 'react' + +// Types +import {Variable} from '@influxdata/influx' + +// Styles +import 'src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.scss' + +interface Props { + variable: Variable +} + +class VariableItem extends PureComponent { + public render() { + const {variable} = this.props + return ( +
+
{variable.name}
+
+ ) + } +} + +export default VariableItem diff --git a/ui/src/timeMachine/components/variableToolbar/VariableToolbar.scss b/ui/src/timeMachine/components/variableToolbar/VariableToolbar.scss new file mode 100644 index 0000000000..6cf3b60d84 --- /dev/null +++ b/ui/src/timeMachine/components/variableToolbar/VariableToolbar.scss @@ -0,0 +1,42 @@ +@import 'src/style/modules'; + +.variable-toolbar { + height: 100%; + display: flex; + flex-direction: column; + background-color: $g3-castle; + font-size: 13px; +} + +.variables-toolbar--list { + padding-bottom: $ix-marg-a; +} + +.variables-toolbar--item { + position: relative; +} + +.variables-toolbar--label, +.variables-toolbar--separator { + height: 30px; + display: flex; + align-items: center; + padding-left: $ix-marg-b; +} + +.variables-toolbar--label { + font-family: 'RobotoMono', monospace; + cursor: pointer; + + &:hover, + &:active { + background-color: $g4-onyx; + color: $c-laser; + } +} + +.variables-toolbar--separator { + background-color: $g6-smoke; + font-weight: 600; + color: $g18-cloud; +} diff --git a/ui/src/timeMachine/components/variableToolbar/VariableToolbar.tsx b/ui/src/timeMachine/components/variableToolbar/VariableToolbar.tsx new file mode 100644 index 0000000000..f3ef903a54 --- /dev/null +++ b/ui/src/timeMachine/components/variableToolbar/VariableToolbar.tsx @@ -0,0 +1,65 @@ +// Libraries +import React, {PureComponent} from 'react' + +// Components +import SearchBar from 'src/timeMachine/components/SearchBar' +import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar' +import VariableItem from 'src/timeMachine/components/variableToolbar/VariableItem' + +// Styles +import 'src/timeMachine/components/variableToolbar/VariableToolbar.scss' +import {SpinnerContainer, TechnoSpinner} from '@influxdata/clockface' +import FetchVariables from 'src/timeMachine/components/variableToolbar/FetchVariables' + +interface State { + searchTerm: string +} + +class VariableToolbar extends PureComponent<{}, State> { + constructor(props) { + super(props) + + this.state = { + searchTerm: '', + } + } + + public render() { + return ( + + {(variables, loading) => { + return ( + } + > +
+ + +
+ {variables + .filter(v => { + return v.name.includes(this.state.searchTerm) + }) + .map(v => { + return + })} +
+
+
+
+ ) + }} +
+ ) + } + + private handleSearch = (searchTerm: string): void => { + this.setState({searchTerm}) + } +} + +export default VariableToolbar From dff331667e2138bb48d3558c665b55cdab367d53 Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Fri, 22 Feb 2019 08:51:38 -0800 Subject: [PATCH 44/54] Rename layer "aesthetics" to "mappings" --- ui/src/minard/components/Histogram.tsx | 12 ++++++------ ui/src/minard/components/HistogramBars.tsx | 10 +++++----- ui/src/minard/index.ts | 2 +- ui/src/minard/utils/getBarFill.ts | 4 ++-- .../minard/utils/getHistogramTooltipProps.ts | 12 ++++++------ ui/src/minard/utils/plotEnvReducer.ts | 19 ++++++++++--------- 6 files changed, 30 insertions(+), 29 deletions(-) diff --git a/ui/src/minard/components/Histogram.tsx b/ui/src/minard/components/Histogram.tsx index 977072f72d..91fcad306a 100644 --- a/ui/src/minard/components/Histogram.tsx +++ b/ui/src/minard/components/Histogram.tsx @@ -47,7 +47,7 @@ export const Histogram: SFC = ({ const layer = useLayer( env, () => { - const [table, aesthetics] = bin( + const [table, mappings] = bin( baseTable, x, xDomain, @@ -56,7 +56,7 @@ export const Histogram: SFC = ({ position ) - return {table, aesthetics, colors, scales: {}} + return {table, mappings, colors, scales: {}} }, [baseTable, xDomain, x, fill, position, binCount, colors] ) @@ -75,12 +75,12 @@ export const Histogram: SFC = ({ }, } = env - const {aesthetics, table} = layer + const {mappings, table} = layer const hoveredRowIndices = findHoveredRowIndices( - table.columns[aesthetics.xMin], - table.columns[aesthetics.xMax], - table.columns[aesthetics.yMax], + table.columns[mappings.xMin], + table.columns[mappings.xMax], + table.columns[mappings.yMax], hoverX, hoverY, xScale, diff --git a/ui/src/minard/components/HistogramBars.tsx b/ui/src/minard/components/HistogramBars.tsx index 8e1938e270..92ff8c4593 100644 --- a/ui/src/minard/components/HistogramBars.tsx +++ b/ui/src/minard/components/HistogramBars.tsx @@ -24,11 +24,11 @@ const drawBars = ( ): void => { clearCanvas(canvas, width, height) - const {table, aesthetics} = layer - const xMinCol = table.columns[aesthetics.xMin] - const xMaxCol = table.columns[aesthetics.xMax] - const yMinCol = table.columns[aesthetics.yMin] - const yMaxCol = table.columns[aesthetics.yMax] + const {table, mappings} = layer + const xMinCol = table.columns[mappings.xMin] + const xMaxCol = table.columns[mappings.xMax] + const yMinCol = table.columns[mappings.yMin] + const yMaxCol = table.columns[mappings.yMax] const context = canvas.getContext('2d') diff --git a/ui/src/minard/index.ts b/ui/src/minard/index.ts index 598c5b052d..a7a42ddcfb 100644 --- a/ui/src/minard/index.ts +++ b/ui/src/minard/index.ts @@ -39,7 +39,7 @@ export interface AestheticScaleMappings { export interface Layer { table?: Table - aesthetics: AestheticDataMappings + mappings: AestheticDataMappings scales: AestheticScaleMappings colors?: string[] xDomain?: [number, number] diff --git a/ui/src/minard/utils/getBarFill.ts b/ui/src/minard/utils/getBarFill.ts index 894401d9bd..d11963b2fd 100644 --- a/ui/src/minard/utils/getBarFill.ts +++ b/ui/src/minard/utils/getBarFill.ts @@ -13,11 +13,11 @@ import {getGroupKey} from 'src/minard/utils/getGroupKey' // key”) that the scale uses as a domain // 3. Lookup the scale and get the color via this representation export const getBarFill = ( - {scales, aesthetics, table}: Layer, + {scales, mappings, table}: Layer, i: number ): string => { const fillScale = scales.fill - const values = aesthetics.fill.map(colKey => table.columns[colKey][i]) + const values = mappings.fill.map(colKey => table.columns[colKey][i]) const groupKey = getGroupKey(values) const fill = fillScale(groupKey) diff --git a/ui/src/minard/utils/getHistogramTooltipProps.ts b/ui/src/minard/utils/getHistogramTooltipProps.ts index a657a68018..1827cd38aa 100644 --- a/ui/src/minard/utils/getHistogramTooltipProps.ts +++ b/ui/src/minard/utils/getHistogramTooltipProps.ts @@ -5,14 +5,14 @@ export const getHistogramTooltipProps = ( layer: Layer, rowIndices: number[] ): HistogramTooltipProps => { - const {table, aesthetics} = layer - const xMinCol = table.columns[aesthetics.xMin] - const xMaxCol = table.columns[aesthetics.xMax] - const yMinCol = table.columns[aesthetics.yMin] - const yMaxCol = table.columns[aesthetics.yMax] + const {table, mappings} = layer + const xMinCol = table.columns[mappings.xMin] + const xMaxCol = table.columns[mappings.xMax] + const yMinCol = table.columns[mappings.yMin] + const yMaxCol = table.columns[mappings.yMax] const counts = rowIndices.map(i => { - const grouping = aesthetics.fill.reduce( + const grouping = mappings.fill.reduce( (acc, colName) => ({ ...acc, [colName]: table.columns[colName][i], diff --git a/ui/src/minard/utils/plotEnvReducer.ts b/ui/src/minard/utils/plotEnvReducer.ts index d0509dcad4..990c932ff9 100644 --- a/ui/src/minard/utils/plotEnvReducer.ts +++ b/ui/src/minard/utils/plotEnvReducer.ts @@ -33,7 +33,7 @@ export const INITIAL_PLOT_ENV: PlotEnv = { yDomain: null, baseLayer: { table: {columns: {}, columnTypes: {}}, - aesthetics: {}, + mappings: {}, scales: {}, }, layers: {}, @@ -117,20 +117,21 @@ export const plotEnvReducer = (state: PlotEnv, action: PlotAction): PlotEnv => /* Find all columns in the current in all layers that are mapped to the supplied - aesthetics + aesthetic mappings */ const getColumnsForAesthetics = ( state: PlotEnv, - aesthetics: string[] + mappings: string[] ): any[][] => { const {baseLayer, layers} = state const cols = [] for (const layer of Object.values(layers)) { - for (const aes of aesthetics) { - if (layer.aesthetics[aes]) { - const colName = layer.aesthetics[aes] + for (const mapping of mappings) { + const colName = layer.mappings[mapping] + + if (colName) { const col = layer.table ? layer.table.columns[colName] : baseLayer.table.columns[colName] @@ -272,8 +273,8 @@ const getColorScale = ( of data (for now). So the domain of the scale is a set of "group keys" which represent all possible groupings of data in the layer. */ -const getFillDomain = ({table, aesthetics}: Layer): string[] => { - const fillColKeys = aesthetics.fill +const getFillDomain = ({table, mappings}: Layer): string[] => { + const fillColKeys = mappings.fill if (!fillColKeys.length) { return [] @@ -299,7 +300,7 @@ const setFillScales = (draftState: PlotEnv) => { layers .filter( // Pick out the layers that actually need a fill scale - layer => layer.aesthetics.fill && layer.colors && layer.colors.length + layer => layer.mappings.fill && layer.colors && layer.colors.length ) .forEach(layer => { layer.scales.fill = getColorScale(getFillDomain(layer), layer.colors) From b3f6669184eac21c5362f98f479256709cec0a63 Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Fri, 22 Feb 2019 10:54:07 -0800 Subject: [PATCH 45/54] Refactor vis table shape and types --- ui/src/minard/components/Histogram.tsx | 12 +- ui/src/minard/components/HistogramBars.tsx | 14 +- ui/src/minard/components/HistogramTooltip.tsx | 4 +- ui/src/minard/index.ts | 217 +++++++++--------- ui/src/minard/utils/bin.test.ts | 178 +++++++------- ui/src/minard/utils/bin.ts | 73 +++--- ui/src/minard/utils/findHoveredRowIndices.tsx | 9 +- ui/src/minard/utils/getBarFill.ts | 6 +- .../minard/utils/getHistogramTooltipProps.ts | 15 +- ui/src/minard/utils/isNumeric.ts | 6 + ui/src/minard/utils/plotEnvActions.ts | 4 +- ui/src/minard/utils/plotEnvReducer.ts | 37 +-- ui/src/minard/utils/useLayer.ts | 2 +- ui/src/shared/components/Histogram.tsx | 64 +++--- ui/src/shared/utils/toMinardTable.test.ts | 106 +++++---- ui/src/shared/utils/toMinardTable.ts | 56 ++--- ui/src/timeMachine/actions/index.ts | 6 +- 17 files changed, 436 insertions(+), 373 deletions(-) create mode 100644 ui/src/minard/utils/isNumeric.ts diff --git a/ui/src/minard/components/Histogram.tsx b/ui/src/minard/components/Histogram.tsx index 91fcad306a..dd0b0ed048 100644 --- a/ui/src/minard/components/Histogram.tsx +++ b/ui/src/minard/components/Histogram.tsx @@ -1,6 +1,6 @@ import React, {SFC} from 'react' -import {PlotEnv} from 'src/minard' +import {PlotEnv, HistogramLayer} from 'src/minard' import {bin} from 'src/minard/utils/bin' import HistogramBars from 'src/minard/components/HistogramBars' import HistogramTooltip from 'src/minard/components/HistogramTooltip' @@ -56,10 +56,10 @@ export const Histogram: SFC = ({ position ) - return {table, mappings, colors, scales: {}} + return {type: 'histogram', table, mappings, colors} }, [baseTable, xDomain, x, fill, position, binCount, colors] - ) + ) as HistogramLayer if (!layer) { return null @@ -75,12 +75,10 @@ export const Histogram: SFC = ({ }, } = env - const {mappings, table} = layer + const {table} = layer const hoveredRowIndices = findHoveredRowIndices( - table.columns[mappings.xMin], - table.columns[mappings.xMax], - table.columns[mappings.yMax], + table, hoverX, hoverY, xScale, diff --git a/ui/src/minard/components/HistogramBars.tsx b/ui/src/minard/components/HistogramBars.tsx index 92ff8c4593..4e285ebaed 100644 --- a/ui/src/minard/components/HistogramBars.tsx +++ b/ui/src/minard/components/HistogramBars.tsx @@ -1,6 +1,6 @@ import React, {useRef, useLayoutEffect, SFC} from 'react' -import {Scale, HistogramPosition, Layer} from 'src/minard' +import {Scale, HistogramPosition, HistogramLayer} from 'src/minard' import {clearCanvas} from 'src/minard/utils/clearCanvas' import {getBarFill} from 'src/minard/utils/getBarFill' @@ -11,7 +11,7 @@ const BAR_PADDING = 1.5 interface Props { width: number height: number - layer: Layer + layer: HistogramLayer xScale: Scale yScale: Scale position: HistogramPosition @@ -24,11 +24,11 @@ const drawBars = ( ): void => { clearCanvas(canvas, width, height) - const {table, mappings} = layer - const xMinCol = table.columns[mappings.xMin] - const xMaxCol = table.columns[mappings.xMax] - const yMinCol = table.columns[mappings.yMin] - const yMaxCol = table.columns[mappings.yMax] + const {table} = layer + const xMinCol = table.columns.xMin.data + const xMaxCol = table.columns.xMax.data + const yMinCol = table.columns.yMin.data + const yMaxCol = table.columns.yMax.data const context = canvas.getContext('2d') diff --git a/ui/src/minard/components/HistogramTooltip.tsx b/ui/src/minard/components/HistogramTooltip.tsx index 1572a4249b..19e53e4885 100644 --- a/ui/src/minard/components/HistogramTooltip.tsx +++ b/ui/src/minard/components/HistogramTooltip.tsx @@ -1,6 +1,6 @@ import React, {useRef, SFC} from 'react' -import {HistogramTooltipProps, Layer} from 'src/minard' +import {HistogramTooltipProps, HistogramLayer} from 'src/minard' import {useLayoutStyle} from 'src/minard/utils/useLayoutStyle' import {useMousePos} from 'src/minard/utils/useMousePos' import {getHistogramTooltipProps} from 'src/minard/utils/getHistogramTooltipProps' @@ -12,7 +12,7 @@ interface Props { hoverX: number hoverY: number tooltip?: (props: HistogramTooltipProps) => JSX.Element - layer: Layer + layer: HistogramLayer hoveredRowIndices: number[] | null } diff --git a/ui/src/minard/index.ts b/ui/src/minard/index.ts index a7a42ddcfb..cbec000dd0 100644 --- a/ui/src/minard/index.ts +++ b/ui/src/minard/index.ts @@ -17,35 +17,120 @@ export { TooltipProps as HistogramTooltipProps, } from 'src/minard/components/Histogram' +export {isNumeric} from 'src/minard/utils/isNumeric' + +export type ColumnType = 'int' | 'uint' | 'float' | 'string' | 'time' | 'bool' + +export type NumericColumnType = 'int' | 'uint' | 'float' | 'time' + +export interface FloatColumn { + data: number[] + type: 'float' +} + +export interface IntColumn { + data: number[] + type: 'int' +} + +export interface UIntColumn { + data: number[] + type: 'uint' +} + +export interface TimeColumn { + data: number[] + type: 'time' +} + +export interface StringColumn { + data: string[] + type: 'string' +} + +export interface BoolColumn { + data: boolean[] + type: 'bool' +} + +export type NumericTableColumn = + | FloatColumn + | IntColumn + | UIntColumn + | TimeColumn + +export type TableColumn = + | FloatColumn + | IntColumn + | UIntColumn + | TimeColumn + | StringColumn + | BoolColumn + +export interface Table { + length: number + columns: { + [columnName: string]: TableColumn + } +} + +export type LayerType = 'base' | 'histogram' + export interface Scale { (x: D): R invert?: (y: R) => D } -export interface AestheticDataMappings { - x?: string - fill?: string[] - xMin?: string - xMax?: string - yMin?: string - yMax?: string +export interface BaseLayerMappings {} + +export interface BaseLayerScales { + x: Scale + y: Scale } -export interface AestheticScaleMappings { - x?: Scale - y?: Scale - fill?: Scale +export interface BaseLayer { + type: 'base' + table: Table + scales: BaseLayerScales + mappings: {} + xDomain: [number, number] + yDomain: [number, number] } -export interface Layer { - table?: Table - mappings: AestheticDataMappings - scales: AestheticScaleMappings - colors?: string[] - xDomain?: [number, number] - yDomain?: [number, number] +export interface HistogramTable extends Table { + columns: { + xMin: NumericTableColumn + xMax: NumericTableColumn + yMin: IntColumn + yMax: IntColumn + [fillColumn: string]: TableColumn + } + length: number } +export interface HistogramMappings { + xMin: 'xMin' + xMax: 'xMax' + yMin: 'yMin' + yMax: 'yMax' + fill: string[] +} + +export interface HistogramScales { + // x and y scale are from the `BaseLayer` + fill: Scale +} + +export interface HistogramLayer { + type: 'histogram' + table: HistogramTable + mappings: HistogramMappings + scales: HistogramScales + colors: string[] +} + +export type Layer = BaseLayer | HistogramLayer + export interface Margins { top: number right: number @@ -69,103 +154,9 @@ export interface PlotEnv { xDomain: [number, number] yDomain: [number, number] - baseLayer: Layer + baseLayer: BaseLayer layers: {[layerKey: string]: Layer} hoverX: number hoverY: number dispatch: (action: PlotAction) => void } - -export enum ColumnType { - Numeric = 'numeric', - Categorical = 'categorical', - Temporal = 'temporal', - Boolean = 'bool', -} - -export interface Table { - columns: {[columnName: string]: any[]} - columnTypes: {[columnName: string]: ColumnType} -} - -// export enum InterpolationKind { -// Linear = 'linear', -// MonotoneX = 'monotoneX', -// MonotoneY = 'monotoneY', -// Cubic = 'cubic', -// Step = 'step', -// StepBefore = 'stepBefore', -// StepAfter = 'stepAfter', -// } - -// export interface LineProps { -// x?: string -// y?: string -// stroke?: string -// strokeWidth?: string -// interpolate?: InterpolationKind -// } - -// export enum AreaPositionKind { -// Stack = 'stack', -// Overlay = 'overlay', -// } - -// export interface AreaProps { -// x?: string -// y?: string -// position?: AreaPositionKind -// } - -// export enum ShapeKind { -// Point = 'point', -// // Spade, Heart, Club, Triangle, Hexagon, etc. -// } - -// export interface PointProps { -// x?: string -// y?: string -// fill?: string -// shape?: ShapeKind -// radius?: number -// alpha?: number -// } - -// export interface ContinuousBarProps { -// x0?: string -// x1?: string -// y?: string -// fill?: string -// } - -// export enum DiscreteBarPositionKind { -// Stack = 'stack', -// Dodge = 'dodge', -// } - -// export interface DiscreteBarProps { -// x?: string -// y?: string -// fill?: string -// position?: DiscreteBarPositionKind -// } - -// export interface StepLineProps { -// x0?: string -// x1?: string -// y?: string -// } - -// export interface StepAreaProps { -// x0?: string -// x1?: string -// y?: string -// position?: AreaPositionKind -// } - -// export interface Bin2DProps { -// x?: string -// y?: string -// binWidth?: number -// binHeight?: number -// } diff --git a/ui/src/minard/utils/bin.test.ts b/ui/src/minard/utils/bin.test.ts index a319e24537..27665fc63f 100644 --- a/ui/src/minard/utils/bin.test.ts +++ b/ui/src/minard/utils/bin.test.ts @@ -1,39 +1,44 @@ -import {HistogramPosition, ColumnType} from 'src/minard' +import {HistogramPosition, Table} from 'src/minard' import {bin} from 'src/minard/utils/bin' -const TABLE = { +const TABLE: Table = { columns: { - _value: [70, 56, 60, 100, 76, 0, 63, 48, 79, 67], - _field: [ - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_idle', - 'usage_idle', - 'usage_idle', - 'usage_idle', - 'usage_idle', - ], - cpu: [ - 'cpu0', - 'cpu0', - 'cpu0', - 'cpu1', - 'cpu1', - 'cpu0', - 'cpu0', - 'cpu0', - 'cpu1', - 'cpu1', - ], - }, - columnTypes: { - _value: ColumnType.Numeric, - _field: ColumnType.Categorical, - cpu: ColumnType.Categorical, + _value: { + data: [70, 56, 60, 100, 76, 0, 63, 48, 79, 67], + type: 'int', + }, + _field: { + data: [ + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_idle', + 'usage_idle', + 'usage_idle', + 'usage_idle', + 'usage_idle', + ], + type: 'string', + }, + cpu: { + data: [ + 'cpu0', + 'cpu0', + 'cpu0', + 'cpu1', + 'cpu1', + 'cpu0', + 'cpu0', + 'cpu0', + 'cpu1', + 'cpu1', + ], + type: 'string', + }, }, + length: 10, } describe('bin', () => { @@ -41,20 +46,15 @@ describe('bin', () => { const actual = bin(TABLE, '_value', null, [], 5, HistogramPosition.Stacked) const expected = [ { - columnTypes: { - xMax: 'numeric', - xMin: 'numeric', - yMax: 'numeric', - yMin: 'numeric', - }, columns: { - xMax: [20, 40, 60, 80, 100], - xMin: [0, 20, 40, 60, 80], - yMax: [1, 0, 2, 6, 1], - yMin: [0, 0, 0, 0, 0], + xMin: {data: [0, 20, 40, 60, 80], type: 'int'}, + xMax: {data: [20, 40, 60, 80, 100], type: 'int'}, + yMin: {data: [0, 0, 0, 0, 0], type: 'int'}, + yMax: {data: [1, 0, 2, 6, 1], type: 'int'}, }, + length: 5, }, - {fill: [], xMax: 'xMax', xMin: 'xMin', yMax: 'yMax', yMin: 'yMin'}, + {xMin: 'xMin', xMax: 'xMax', yMin: 'yMin', yMax: 'yMax', fill: []}, ] expect(actual).toEqual(expected) @@ -71,22 +71,25 @@ describe('bin', () => { )[0].columns const expected = { - _field: [ - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_idle', - 'usage_idle', - 'usage_idle', - 'usage_idle', - 'usage_idle', - ], - xMax: [20, 40, 60, 80, 100, 20, 40, 60, 80, 100], - xMin: [0, 20, 40, 60, 80, 0, 20, 40, 60, 80], - yMax: [0, 0, 1, 3, 1, 1, 0, 2, 6, 1], - yMin: [0, 0, 0, 0, 0, 0, 0, 1, 3, 1], + xMin: {data: [0, 20, 40, 60, 80, 0, 20, 40, 60, 80], type: 'int'}, + xMax: {data: [20, 40, 60, 80, 100, 20, 40, 60, 80, 100], type: 'int'}, + yMin: {data: [0, 0, 0, 0, 0, 0, 0, 1, 3, 1], type: 'int'}, + yMax: {data: [0, 0, 1, 3, 1, 1, 0, 2, 6, 1], type: 'int'}, + _field: { + data: [ + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_idle', + 'usage_idle', + 'usage_idle', + 'usage_idle', + 'usage_idle', + ], + type: 'string', + }, } expect(actual).toEqual(expected) @@ -103,22 +106,25 @@ describe('bin', () => { )[0].columns const expected = { - _field: [ - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_guest', - 'usage_idle', - 'usage_idle', - 'usage_idle', - 'usage_idle', - 'usage_idle', - ], - xMax: [20, 40, 60, 80, 100, 20, 40, 60, 80, 100], - xMin: [0, 20, 40, 60, 80, 0, 20, 40, 60, 80], - yMax: [0, 0, 1, 3, 1, 1, 0, 1, 3, 0], - yMin: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + xMin: {data: [0, 20, 40, 60, 80, 0, 20, 40, 60, 80], type: 'int'}, + xMax: {data: [20, 40, 60, 80, 100, 20, 40, 60, 80, 100], type: 'int'}, + yMin: {data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], type: 'int'}, + yMax: {data: [0, 0, 1, 3, 1, 1, 0, 1, 3, 0], type: 'int'}, + _field: { + data: [ + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_guest', + 'usage_idle', + 'usage_idle', + 'usage_idle', + 'usage_idle', + 'usage_idle', + ], + type: 'string', + }, } expect(actual).toEqual(expected) @@ -135,10 +141,16 @@ describe('bin', () => { )[0].columns const expected = { - xMax: [-160, -120, -80, -40, 0, 40, 80, 120, 160, 200], - xMin: [-200, -160, -120, -80, -40, 0, 40, 80, 120, 160], - yMax: [0, 0, 0, 0, 0, 1, 8, 1, 0, 0], - yMin: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + xMin: { + data: [-200, -160, -120, -80, -40, 0, 40, 80, 120, 160], + type: 'int', + }, + xMax: { + data: [-160, -120, -80, -40, 0, 40, 80, 120, 160, 200], + type: 'int', + }, + yMin: {data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], type: 'int'}, + yMax: {data: [0, 0, 0, 0, 0, 1, 8, 1, 0, 0], type: 'int'}, } expect(actual).toEqual(expected) @@ -155,10 +167,10 @@ describe('bin', () => { )[0].columns const expected = { - xMax: [60, 70, 80], - xMin: [50, 60, 70], - yMax: [1, 3, 3], - yMin: [0, 0, 0], + xMin: {data: [50, 60, 70], type: 'int'}, + xMax: {data: [60, 70, 80], type: 'int'}, + yMin: {data: [0, 0, 0], type: 'int'}, + yMax: {data: [1, 3, 3], type: 'int'}, } expect(actual).toEqual(expected) diff --git a/ui/src/minard/utils/bin.ts b/ui/src/minard/utils/bin.ts index 243cb9777e..fe31ca3579 100644 --- a/ui/src/minard/utils/bin.ts +++ b/ui/src/minard/utils/bin.ts @@ -1,6 +1,13 @@ import {extent, range, thresholdSturges} from 'd3-array' -import {Table, HistogramPosition, ColumnType} from 'src/minard' +import { + Table, + HistogramTable, + HistogramMappings, + HistogramPosition, + NumericColumnType, + isNumeric, +} from 'src/minard' import {assert} from 'src/minard/utils/assert' import {getGroupKey} from 'src/minard/utils/getGroupKey' @@ -37,15 +44,14 @@ export const bin = ( groupColNames: string[] = [], binCount: number, position: HistogramPosition -) => { - const xCol = table.columns[xColName] - const xColType = table.columnTypes[xColName] +): [HistogramTable, HistogramMappings] => { + const col = table.columns[xColName] - assert(`could not find column "${xColName}"`, !!xCol) - assert( - `unsupported value column type "${xColType}"`, - xColType === ColumnType.Numeric || xColType === ColumnType.Temporal - ) + assert(`could not find column "${xColName}"`, !!col) + assert(`unsupported value column type "${col.type}"`, isNumeric(col.type)) + + const xCol = col.data as number[] + const xColType = col.type as NumericColumnType if (!binCount) { binCount = thresholdSturges(xCol) @@ -91,24 +97,37 @@ export const bin = ( } // Next, build up a tabular representation of each of these bins by group + const groupKeys = Object.keys(groupsByGroupKey) const statTable = { - columns: {xMin: [], xMax: [], yMin: [], yMax: []}, - columnTypes: { - xMin: xColType, - xMax: xColType, - yMin: ColumnType.Numeric, - yMax: ColumnType.Numeric, + columns: { + xMin: { + data: [], + type: xColType, + }, + xMax: { + data: [], + type: xColType, + }, + yMin: { + data: [], + type: 'int', + }, + yMax: { + data: [], + type: 'int', + }, }, + length: binCount * groupKeys.length, } // Include original columns used to group data in the resulting table for (const name of groupColNames) { - statTable.columns[name] = [] - statTable.columnTypes[name] = table.columnTypes[name] + statTable.columns[name] = { + data: [], + type: table.columns[name].type, + } } - const groupKeys = Object.keys(groupsByGroupKey) - for (let i = 0; i < groupKeys.length; i++) { const groupKey = groupKeys[i] @@ -121,18 +140,18 @@ export const bin = ( .reduce((sum, k) => sum + (bin.values[k] || 0), 0) } - statTable.columns.xMin.push(bin.min) - statTable.columns.xMax.push(bin.max) - statTable.columns.yMin.push(yMin) - statTable.columns.yMax.push(yMin + (bin.values[groupKey] || 0)) + statTable.columns.xMin.data.push(bin.min) + statTable.columns.xMax.data.push(bin.max) + statTable.columns.yMin.data.push(yMin) + statTable.columns.yMax.data.push(yMin + (bin.values[groupKey] || 0)) for (const [k, v] of Object.entries(groupsByGroupKey[groupKey])) { - statTable.columns[k].push(v) + statTable.columns[k].data.push(v) } } } - const mappings: any = { + const mappings: HistogramMappings = { xMin: 'xMin', xMax: 'xMax', yMin: 'yMin', @@ -140,7 +159,7 @@ export const bin = ( fill: groupColNames, } - return [statTable, mappings] + return [statTable as HistogramTable, mappings] } const createBins = ( @@ -186,7 +205,7 @@ const getGroup = (table: Table, groupColNames: string[], i: number) => { const result = {} for (const key of groupColNames) { - result[key] = table.columns[key][i] + result[key] = table.columns[key].data[i] } return result diff --git a/ui/src/minard/utils/findHoveredRowIndices.tsx b/ui/src/minard/utils/findHoveredRowIndices.tsx index c87882cc54..8187ebca0a 100644 --- a/ui/src/minard/utils/findHoveredRowIndices.tsx +++ b/ui/src/minard/utils/findHoveredRowIndices.tsx @@ -1,10 +1,10 @@ import {Scale} from 'src/minard' import {range} from 'd3-array' +import {HistogramTable} from 'src/minard' + export const findHoveredRowIndices = ( - xMinCol: number[], - xMaxCol: number[], - yMaxCol: number[], + table: HistogramTable, hoverX: number, hoverY: number, xScale: Scale, @@ -14,6 +14,9 @@ export const findHoveredRowIndices = ( return null } + const xMinCol = table.columns.xMin.data + const xMaxCol = table.columns.xMax.data + const yMaxCol = table.columns.yMax.data const dataX = xScale.invert(hoverX) const dataY = yScale.invert(hoverY) diff --git a/ui/src/minard/utils/getBarFill.ts b/ui/src/minard/utils/getBarFill.ts index d11963b2fd..7abaffa95c 100644 --- a/ui/src/minard/utils/getBarFill.ts +++ b/ui/src/minard/utils/getBarFill.ts @@ -1,4 +1,4 @@ -import {Layer} from 'src/minard' +import {HistogramLayer} from 'src/minard' import {getGroupKey} from 'src/minard/utils/getGroupKey' // Given a histogram `Layer` and the index of row in its table, this function @@ -13,11 +13,11 @@ import {getGroupKey} from 'src/minard/utils/getGroupKey' // key”) that the scale uses as a domain // 3. Lookup the scale and get the color via this representation export const getBarFill = ( - {scales, mappings, table}: Layer, + {scales, mappings, table}: HistogramLayer, i: number ): string => { const fillScale = scales.fill - const values = mappings.fill.map(colKey => table.columns[colKey][i]) + const values = mappings.fill.map(colKey => table.columns[colKey].data[i]) const groupKey = getGroupKey(values) const fill = fillScale(groupKey) diff --git a/ui/src/minard/utils/getHistogramTooltipProps.ts b/ui/src/minard/utils/getHistogramTooltipProps.ts index 1827cd38aa..a125faad46 100644 --- a/ui/src/minard/utils/getHistogramTooltipProps.ts +++ b/ui/src/minard/utils/getHistogramTooltipProps.ts @@ -1,21 +1,22 @@ -import {HistogramTooltipProps, Layer} from 'src/minard' +import {HistogramTooltipProps, HistogramLayer} from 'src/minard' import {getBarFill} from 'src/minard/utils/getBarFill' export const getHistogramTooltipProps = ( - layer: Layer, + layer: HistogramLayer, rowIndices: number[] ): HistogramTooltipProps => { const {table, mappings} = layer - const xMinCol = table.columns[mappings.xMin] - const xMaxCol = table.columns[mappings.xMax] - const yMinCol = table.columns[mappings.yMin] - const yMaxCol = table.columns[mappings.yMax] + + const xMinCol = table.columns.xMin.data + const xMaxCol = table.columns.xMax.data + const yMinCol = table.columns.yMin.data + const yMaxCol = table.columns.yMax.data const counts = rowIndices.map(i => { const grouping = mappings.fill.reduce( (acc, colName) => ({ ...acc, - [colName]: table.columns[colName][i], + [colName]: table.columns[colName].data[i], }), {} ) diff --git a/ui/src/minard/utils/isNumeric.ts b/ui/src/minard/utils/isNumeric.ts new file mode 100644 index 0000000000..1fc5683c30 --- /dev/null +++ b/ui/src/minard/utils/isNumeric.ts @@ -0,0 +1,6 @@ +import {ColumnType} from 'src/minard' + +const NUMERIC_TYPES = new Set(['uint', 'int', 'float', 'time']) + +export const isNumeric = (columnType: ColumnType): boolean => + NUMERIC_TYPES.has(columnType) diff --git a/ui/src/minard/utils/plotEnvActions.ts b/ui/src/minard/utils/plotEnvActions.ts index b28c09f33d..4005622eab 100644 --- a/ui/src/minard/utils/plotEnvActions.ts +++ b/ui/src/minard/utils/plotEnvActions.ts @@ -13,13 +13,13 @@ interface RegisterLayerAction { type: 'REGISTER_LAYER' payload: { layerKey: string - layer: Layer + layer: Partial } } export const registerLayer = ( layerKey: string, - layer: Layer + layer: Partial ): RegisterLayerAction => ({ type: 'REGISTER_LAYER', payload: {layerKey, layer}, diff --git a/ui/src/minard/utils/plotEnvReducer.ts b/ui/src/minard/utils/plotEnvReducer.ts index 990c932ff9..48b8f55829 100644 --- a/ui/src/minard/utils/plotEnvReducer.ts +++ b/ui/src/minard/utils/plotEnvReducer.ts @@ -6,6 +6,7 @@ import chroma from 'chroma-js' import { PlotEnv, Layer, + HistogramLayer, Scale, PLOT_PADDING, TICK_CHAR_WIDTH, @@ -16,6 +17,9 @@ import { import {PlotAction} from 'src/minard/utils/plotEnvActions' import {getGroupKey} from 'src/minard/utils/getGroupKey' +const DEFAULT_X_DOMAIN: [number, number] = [0, 1] +const DEFAULT_Y_DOMAIN: [number, number] = [0, 1] + export const INITIAL_PLOT_ENV: PlotEnv = { width: 0, height: 0, @@ -32,9 +36,15 @@ export const INITIAL_PLOT_ENV: PlotEnv = { xDomain: null, yDomain: null, baseLayer: { - table: {columns: {}, columnTypes: {}}, + type: 'base', + table: {columns: {}, length: 0}, + xDomain: DEFAULT_X_DOMAIN, + yDomain: DEFAULT_Y_DOMAIN, mappings: {}, - scales: {}, + scales: { + x: null, + y: null, + }, }, layers: {}, hoverX: null, @@ -42,16 +52,13 @@ export const INITIAL_PLOT_ENV: PlotEnv = { dispatch: () => {}, } -const DEFAULT_X_DOMAIN: [number, number] = [0, 1] -const DEFAULT_Y_DOMAIN: [number, number] = [0, 1] - export const plotEnvReducer = (state: PlotEnv, action: PlotAction): PlotEnv => produce(state, draftState => { switch (action.type) { case 'REGISTER_LAYER': { const {layerKey, layer} = action.payload - draftState.layers[layerKey] = layer + draftState.layers[layerKey] = {...layer, scales: {}} as Layer setXDomain(draftState) setYDomain(draftState) @@ -133,8 +140,8 @@ const getColumnsForAesthetics = ( if (colName) { const col = layer.table - ? layer.table.columns[colName] - : baseLayer.table.columns[colName] + ? layer.table.columns[colName].data + : baseLayer.table.columns[colName].data cols.push(col) } @@ -273,7 +280,7 @@ const getColorScale = ( of data (for now). So the domain of the scale is a set of "group keys" which represent all possible groupings of data in the layer. */ -const getFillDomain = ({table, mappings}: Layer): string[] => { +const getFillDomain = ({table, mappings}: HistogramLayer): string[] => { const fillColKeys = mappings.fill if (!fillColKeys.length) { @@ -281,10 +288,9 @@ const getFillDomain = ({table, mappings}: Layer): string[] => { } const fillDomain = new Set() - const n = Object.values(table.columns)[0].length - for (let i = 0; i < n; i++) { - fillDomain.add(getGroupKey(fillColKeys.map(k => table.columns[k][i]))) + for (let i = 0; i < table.length; i++) { + fillDomain.add(getGroupKey(fillColKeys.map(k => table.columns[k].data[i]))) } return [...fillDomain].sort() @@ -298,11 +304,8 @@ const setFillScales = (draftState: PlotEnv) => { const layers = Object.values(draftState.layers) layers - .filter( - // Pick out the layers that actually need a fill scale - layer => layer.mappings.fill && layer.colors && layer.colors.length - ) - .forEach(layer => { + .filter(layer => layer.type === 'histogram') + .forEach((layer: HistogramLayer) => { layer.scales.fill = getColorScale(getFillDomain(layer), layer.colors) }) } diff --git a/ui/src/minard/utils/useLayer.ts b/ui/src/minard/utils/useLayer.ts index 222ca2cd72..87b5afb6a4 100644 --- a/ui/src/minard/utils/useLayer.ts +++ b/ui/src/minard/utils/useLayer.ts @@ -10,7 +10,7 @@ import {registerLayer, unregisterLayer} from 'src/minard/utils/plotEnvActions' */ export const useLayer = ( env: PlotEnv, - layerFactory: () => Layer, + layerFactory: () => Partial, inputs?: DependencyList ) => { const {current: layerKey} = useRef(uuid.v4()) diff --git a/ui/src/shared/components/Histogram.tsx b/ui/src/shared/components/Histogram.tsx index 796ce4d882..17305f74cb 100644 --- a/ui/src/shared/components/Histogram.tsx +++ b/ui/src/shared/components/Histogram.tsx @@ -5,8 +5,8 @@ import {AutoSizer} from 'react-virtualized' import { Plot as MinardPlot, Histogram as MinardHistogram, - ColumnType, Table, + isNumeric, } from 'src/minard' // Components @@ -52,18 +52,18 @@ type Props = OwnProps & DispatchProps */ const resolveMappings = ( table: Table, - preferredXColumn: string, - preferredFillColumns: string[] = [] + preferredXColumnName: string, + preferredFillColumnNames: string[] = [] ): {x: string; fill: string[]} => { - let x: string = preferredXColumn + let x: string = preferredXColumnName - if (!table.columns[x] || table.columnTypes[x] !== ColumnType.Numeric) { - x = Object.entries(table.columnTypes) - .filter(([__, type]) => type === ColumnType.Numeric) + if (!table.columns[x] || !isNumeric(table.columns[x].type)) { + x = Object.entries(table.columns) + .filter(([__, {type}]) => isNumeric(type)) .map(([name]) => name)[0] } - let fill = preferredFillColumns || [] + let fill = preferredFillColumnNames || [] fill = fill.filter(name => table.columns[name]) @@ -99,27 +99,33 @@ const Histogram: SFC = ({ return ( - {({width, height}) => ( - - {env => ( - - )} - - )} + {({width, height}) => { + if (width === 0 || height === 0) { + return null + } + + return ( + + {env => ( + + )} + + ) + }} ) } diff --git a/ui/src/shared/utils/toMinardTable.test.ts b/ui/src/shared/utils/toMinardTable.test.ts index 7f9ac152dd..a7b8cbb19e 100644 --- a/ui/src/shared/utils/toMinardTable.test.ts +++ b/ui/src/shared/utils/toMinardTable.test.ts @@ -20,31 +20,42 @@ describe('toMinardTable', () => { const tables = parseResponse(CSV) const actual = toMinardTable(tables) const expected = { - schemaConflicts: [], table: { - columnTypes: { - _field: 'categorical', - _measurement: 'categorical', - _start: 'temporal', - _stop: 'temporal', - _time: 'temporal', - _value: 'numeric', - cpu: 'categorical', - host: 'categorical', - result: 'categorical', - }, columns: { - _field: ['usage_guest', 'usage_guest', 'usage_guest', 'usage_guest'], - _measurement: ['cpu', 'cpu', 'cpu', 'cpu'], - _start: [1549064312524, 1549064312524, 1549064312524, 1549064312524], - _stop: [1549064342524, 1549064342524, 1549064342524, 1549064342524], - _time: [1549064313000, 1549064323000, 1549064313000, 1549064323000], - _value: [10, 20, 30, 40], - cpu: ['cpu-total', 'cpu-total', 'cpu0', 'cpu0'], - host: ['oox4k.local', 'oox4k.local', 'oox4k.local', 'oox4k.local'], - result: ['_result', '_result', '_result', '_result'], + result: { + data: ['_result', '_result', '_result', '_result'], + type: 'string', + }, + _start: { + data: [1549064312524, 1549064312524, 1549064312524, 1549064312524], + type: 'time', + }, + _stop: { + data: [1549064342524, 1549064342524, 1549064342524, 1549064342524], + type: 'time', + }, + _time: { + data: [1549064313000, 1549064323000, 1549064313000, 1549064323000], + type: 'time', + }, + _value: {data: [10, 20, 30, 40], type: 'float'}, + _field: { + data: ['usage_guest', 'usage_guest', 'usage_guest', 'usage_guest'], + type: 'string', + }, + _measurement: {data: ['cpu', 'cpu', 'cpu', 'cpu'], type: 'string'}, + cpu: { + data: ['cpu-total', 'cpu-total', 'cpu0', 'cpu0'], + type: 'string', + }, + host: { + data: ['oox4k.local', 'oox4k.local', 'oox4k.local', 'oox4k.local'], + type: 'string', + }, }, + length: 4, }, + schemaConflicts: [], } expect(actual).toEqual(expected) @@ -68,31 +79,42 @@ describe('toMinardTable', () => { const tables = parseResponse(CSV) const actual = toMinardTable(tables) const expected = { - schemaConflicts: ['_value'], table: { - columnTypes: { - _field: 'categorical', - _measurement: 'categorical', - _start: 'temporal', - _stop: 'temporal', - _time: 'temporal', - _value: 'numeric', - cpu: 'categorical', - host: 'categorical', - result: 'categorical', - }, columns: { - _field: ['usage_guest', 'usage_guest', 'usage_guest', 'usage_guest'], - _measurement: ['cpu', 'cpu', 'cpu', 'cpu'], - _start: [1549064312524, 1549064312524, 1549064312524, 1549064312524], - _stop: [1549064342524, 1549064342524, 1549064342524, 1549064342524], - _time: [1549064313000, 1549064323000, 1549064313000, 1549064323000], - _value: [10, 20, undefined, undefined], - cpu: ['cpu-total', 'cpu-total', 'cpu0', 'cpu0'], - host: ['oox4k.local', 'oox4k.local', 'oox4k.local', 'oox4k.local'], - result: ['_result', '_result', '_result', '_result'], + result: { + data: ['_result', '_result', '_result', '_result'], + type: 'string', + }, + _start: { + data: [1549064312524, 1549064312524, 1549064312524, 1549064312524], + type: 'time', + }, + _stop: { + data: [1549064342524, 1549064342524, 1549064342524, 1549064342524], + type: 'time', + }, + _time: { + data: [1549064313000, 1549064323000, 1549064313000, 1549064323000], + type: 'time', + }, + _value: {data: [10, 20, undefined, undefined], type: 'float'}, + _field: { + data: ['usage_guest', 'usage_guest', 'usage_guest', 'usage_guest'], + type: 'string', + }, + _measurement: {data: ['cpu', 'cpu', 'cpu', 'cpu'], type: 'string'}, + cpu: { + data: ['cpu-total', 'cpu-total', 'cpu0', 'cpu0'], + type: 'string', + }, + host: { + data: ['oox4k.local', 'oox4k.local', 'oox4k.local', 'oox4k.local'], + type: 'string', + }, }, + length: 4, }, + schemaConflicts: ['_value'], } expect(actual).toEqual(expected) diff --git a/ui/src/shared/utils/toMinardTable.ts b/ui/src/shared/utils/toMinardTable.ts index 13c2bbb1cf..71dbde91e2 100644 --- a/ui/src/shared/utils/toMinardTable.ts +++ b/ui/src/shared/utils/toMinardTable.ts @@ -1,5 +1,5 @@ import {FluxTable} from 'src/types' -import {Table, ColumnType} from 'src/minard' +import {Table, ColumnType, isNumeric} from 'src/minard' export const GROUP_KEY_COL_NAME = 'group_key' @@ -53,8 +53,7 @@ export interface ToMinardTableResult { */ export const toMinardTable = (tables: FluxTable[]): ToMinardTableResult => { - const columns = {} - const columnTypes = {} + const outColumns = {} const schemaConflicts = [] let k = 0 @@ -68,34 +67,37 @@ export const toMinardTable = (tables: FluxTable[]): ToMinardTableResult => { } for (let j = 0; j < header.length; j++) { - const column = header[j] + const columnName = header[j] - if (column === '' || column === 'table') { + if (columnName === '' || columnName === 'table') { // Ignore these columns continue } - const columnType = toMinardColumnType(table.dataTypes[column]) + const columnType = toMinardColumnType(table.dataTypes[columnName]) + let columnConflictsSchema = false - if (columnTypes[column] && columnTypes[column] !== columnType) { - schemaConflicts.push(column) + if ( + outColumns[columnName] && + outColumns[columnName].type !== columnType + ) { + schemaConflicts.push(columnName) columnConflictsSchema = true - } else if (!columnTypes[column]) { - columns[column] = [] - columnTypes[column] = columnType + } else if (!outColumns[columnName]) { + outColumns[columnName] = {data: [], type: columnType} } for (let i = 1; i < table.data.length; i++) { let value - if (column === 'result') { + if (columnName === 'result') { value = table.result } else if (!columnConflictsSchema) { value = parseValue(table.data[i][j].trim(), columnType) } - columns[column][k + i - 1] = value + outColumns[columnName].data[k + i - 1] = value } } @@ -103,7 +105,7 @@ export const toMinardTable = (tables: FluxTable[]): ToMinardTableResult => { } const result: ToMinardTableResult = { - table: {columns, columnTypes}, + table: {columns: outColumns, length: k}, schemaConflicts, } @@ -111,12 +113,12 @@ export const toMinardTable = (tables: FluxTable[]): ToMinardTableResult => { } const TO_MINARD_COLUMN_TYPE = { - boolean: ColumnType.Boolean, - unsignedLong: ColumnType.Numeric, - long: ColumnType.Numeric, - double: ColumnType.Numeric, - string: ColumnType.Categorical, - 'dateTime:RFC3339': ColumnType.Temporal, + boolean: 'bool', + unsignedLong: 'uint', + long: 'int', + double: 'float', + string: 'string', + 'dateTime:RFC3339': 'time', } const toMinardColumnType = (fluxDataType: string): ColumnType => { @@ -138,24 +140,24 @@ const parseValue = (value: string, columnType: ColumnType): any => { return NaN } - if (columnType === ColumnType.Boolean && value === 'true') { + if (columnType === 'bool' && value === 'true') { return true } - if (columnType === ColumnType.Boolean && value === 'false') { + if (columnType === 'bool' && value === 'false') { return false } - if (columnType === ColumnType.Categorical) { + if (columnType === 'string') { return value } - if (columnType === ColumnType.Numeric) { - return Number(value) + if (columnType === 'time') { + return Date.parse(value) } - if (columnType === ColumnType.Temporal) { - return Date.parse(value) + if (isNumeric(columnType)) { + return Number(value) } return null diff --git a/ui/src/timeMachine/actions/index.ts b/ui/src/timeMachine/actions/index.ts index 28731a5de2..eb9ca7c44f 100644 --- a/ui/src/timeMachine/actions/index.ts +++ b/ui/src/timeMachine/actions/index.ts @@ -15,7 +15,7 @@ import { } from 'src/types/v2/dashboards' import {TimeMachineTab} from 'src/types/v2/timeMachine' import {Color} from 'src/types/colors' -import {Table, HistogramPosition, ColumnType} from 'src/minard' +import {Table, HistogramPosition, isNumeric} from 'src/minard' export type Action = | QueryBuilderAction @@ -511,8 +511,8 @@ interface TableLoadedAction { } export const tableLoaded = (table: Table): TableLoadedAction => { - const availableXColumns = Object.entries(table.columnTypes) - .filter(([__, type]) => type === ColumnType.Numeric) + const availableXColumns = Object.entries(table.columns) + .filter(([__, {type}]) => isNumeric(type) && type !== 'time') .map(([name]) => name) const invalidGroupColumns = new Set(['_value', '_start', '_stop', '_time']) From 351a718980f3b411a7027e226946ec9c304b1ee1 Mon Sep 17 00:00:00 2001 From: Daniel Campbell Date: Fri, 22 Feb 2019 15:23:17 -0800 Subject: [PATCH 46/54] date picker styles (#12126) * date picker styles * get lint --- .../components/dateRangePicker/DatePicker.tsx | 56 ++++++++++--------- .../dateRangePicker/DateRangePicker.scss | 32 ++++++++--- .../dateRangePicker/DateRangePicker.tsx | 2 +- 3 files changed, 54 insertions(+), 36 deletions(-) diff --git a/ui/src/shared/components/dateRangePicker/DatePicker.tsx b/ui/src/shared/components/dateRangePicker/DatePicker.tsx index 06335ef701..d78b8176b1 100644 --- a/ui/src/shared/components/dateRangePicker/DatePicker.tsx +++ b/ui/src/shared/components/dateRangePicker/DatePicker.tsx @@ -4,9 +4,8 @@ import ReactDatePicker from 'react-datepicker' // Styles import 'react-datepicker/dist/react-datepicker.css' -import {Input} from 'src/clockface' -import {ComponentSize} from '@influxdata/clockface' -import FormLabel from 'src/clockface/components/form_layout/FormLabel' +import {Input, Form, Grid} from 'src/clockface' +import {ComponentSize, Columns} from '@influxdata/clockface' interface Props { label: string @@ -22,37 +21,42 @@ class DatePicker extends PureComponent { const date = new Date(dateTime) return ( - -
- -
-
+
+ + + + + + + +
) } private get customInput() { + const {label} = this.props + return ( ) } diff --git a/ui/src/shared/components/dateRangePicker/DateRangePicker.scss b/ui/src/shared/components/dateRangePicker/DateRangePicker.scss index f36d22586f..1b2a1df2e1 100644 --- a/ui/src/shared/components/dateRangePicker/DateRangePicker.scss +++ b/ui/src/shared/components/dateRangePicker/DateRangePicker.scss @@ -10,10 +10,10 @@ text-align: center; background-color: $g1-raven; border: $ix-border solid $c-pool; - padding: $ix-marg-b; + padding: 0 $ix-marg-b; border-radius: $ix-radius; z-index: 9999; - height: 410px; + height: 416px; .react-datepicker { font-family: $ix-text-font; @@ -25,9 +25,16 @@ display: flex; flex-direction: row; align-items: center; - margin: $ix-marg-b 0; - + margin-top: $ix-marg-b; + .range-picker--date-picker { + margin: $ix-marg-a; + + .react-datepicker-wrapper, + .react-datepicker__input-container { + width: 100%; + } + .range-picker--popper-container { position: relative; } @@ -44,6 +51,12 @@ display: inline-flex; flex-direction: row; + .react-datepicker__month-container { + background-color: $g3-castle; + border-radius: 0 0 $ix-radius $ix-radius; + width: 260px; + } + .react-datepicker__navigation { outline: none; cursor: pointer; @@ -58,8 +71,7 @@ } .range-picker--day { - color: $c-void; - font-weight: 400; + color: $g7-graphite; &:hover { background-color: $c-laser; @@ -68,7 +80,7 @@ } .range-picker--day-in-month { - color: $c-star; + color: $g14-chromium; &:hover { background-color: $c-laser; @@ -86,6 +98,7 @@ } .react-datepicker__header { + border-radius: 0; padding: 0; border: none; @@ -93,6 +106,7 @@ .react-datepicker__day-name { color: $c-rainforest; + font-weight: 700; } .react-datepicker__current-month { @@ -138,8 +152,8 @@ .react-datepicker__time-box { width: 100%; - background-color: $g2-kevlar; - color: $g18-cloud; + background-color: $g3-castle; + color: $g14-chromium; .react-datepicker__time-list { font-size: $ix-text-base; diff --git a/ui/src/shared/components/dateRangePicker/DateRangePicker.tsx b/ui/src/shared/components/dateRangePicker/DateRangePicker.tsx index c8215845f5..26eb800f92 100644 --- a/ui/src/shared/components/dateRangePicker/DateRangePicker.tsx +++ b/ui/src/shared/components/dateRangePicker/DateRangePicker.tsx @@ -26,7 +26,7 @@ interface State { topPosition?: number } -const PICKER_HEIGHT = 410 +const PICKER_HEIGHT = 416 const HORIZONTAL_PADDING = 2 const VERTICAL_PADDING = 15 From 874f484cedf55765f530efea84aaae4fe9bd1e7d Mon Sep 17 00:00:00 2001 From: Alirie Gray Date: Fri, 22 Feb 2019 14:31:43 -0800 Subject: [PATCH 47/54] test(variables): add tests for creation --- ui/cypress/e2e/variables.test.ts | 31 ++++++++++++++ ui/package-lock.json | 41 +++++-------------- .../components/CreateVariableOverlay.tsx | 3 +- .../organizations/components/VariableRow.tsx | 2 +- 4 files changed, 44 insertions(+), 33 deletions(-) create mode 100644 ui/cypress/e2e/variables.test.ts diff --git a/ui/cypress/e2e/variables.test.ts b/ui/cypress/e2e/variables.test.ts new file mode 100644 index 0000000000..e8a1ae1747 --- /dev/null +++ b/ui/cypress/e2e/variables.test.ts @@ -0,0 +1,31 @@ +describe('Variables', () => { + beforeEach(() => { + cy.flush() + + cy.setupUser().then(({body}) => { + cy.signin(body.org.id) + + cy.wrap(body.org).as('org') + cy.visit(`organizations/${body.org.id}/variables_tab`) + }) + }) + + it('can create a variable', () => { + cy.get('.empty-state').within(() => { + cy.contains('Create').click() + }) + + cy.getByInputName('name').type('Little Variable') + cy.getByDataTest('flux-editor').within(() => { + cy.get('textarea').type('filter(fn: (r) => r._field == "cpu")', { + force: true, + }) + }) + + cy.get('form') + .contains('Create') + .click() + + cy.getByDataTest('variable-row').should('have.length', 1) + }) +}) diff --git a/ui/package-lock.json b/ui/package-lock.json index 6d4b1ddfe6..b6c8290d1b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -6085,8 +6085,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -6110,15 +6109,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6135,22 +6132,19 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -6281,8 +6275,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -6296,7 +6289,6 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -6313,7 +6305,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -6322,15 +6313,13 @@ "version": "0.0.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.2.4.tgz", "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==", "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -6351,7 +6340,6 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -6440,8 +6428,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -6455,7 +6442,6 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -6551,8 +6537,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -6594,7 +6579,6 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -6616,7 +6600,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -6665,15 +6648,13 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz", "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=", - "dev": true, - "optional": true + "dev": true } } }, diff --git a/ui/src/organizations/components/CreateVariableOverlay.tsx b/ui/src/organizations/components/CreateVariableOverlay.tsx index 67938b4252..45eac9eee5 100644 --- a/ui/src/organizations/components/CreateVariableOverlay.tsx +++ b/ui/src/organizations/components/CreateVariableOverlay.tsx @@ -59,7 +59,7 @@ export default class CreateOrgOverlay extends PureComponent { onDismiss={this.props.onCloseModal} /> -
+
@@ -94,7 +94,6 @@ export default class CreateOrgOverlay extends PureComponent {