Add initial source management UI

pull/10616/head
Christopher Henn 2018-11-13 11:12:57 -08:00 committed by Chris Henn
parent 71b2eec624
commit 81c0e53c4a
45 changed files with 895 additions and 1390 deletions

View File

@ -251,7 +251,9 @@ func (h *SourceHandler) handlePostSource(w http.ResponseWriter, r *http.Request)
return
}
if err := encodeResponse(ctx, w, http.StatusCreated, req.Source); err != nil {
res := newSourceResponse(req.Source)
if err := encodeResponse(ctx, w, http.StatusCreated, res); err != nil {
EncodeError(ctx, err, w)
return
}

View File

@ -15,7 +15,7 @@ import {getBasepath} from 'src/utils/basepath'
// Components
import App from 'src/App'
import GetSources from 'src/shared/containers/GetSources'
import SetSource from 'src/shared/containers/SetSource'
import SetActiveSource from 'src/shared/containers/SetActiveSource'
import GetOrganizations from 'src/shared/containers/GetOrganizations'
import Setup from 'src/Setup'
import Signin from 'src/Signin'
@ -26,12 +26,12 @@ import OrganizationView from 'src/organizations/containers/OrganizationView'
import TaskEditPage from 'src/tasks/containers/TaskEditPage'
import {DashboardsPage, DashboardPage} from 'src/dashboards'
import DataExplorerPage from 'src/dataExplorer/components/DataExplorerPage'
import {SourcePage, ManageSources} from 'src/sources'
import {UserPage} from 'src/user'
import {LogsPage} from 'src/logs'
import NotFound from 'src/shared/components/NotFound'
import GetLinks from 'src/shared/containers/GetLinks'
import GetMe from 'src/shared/containers/GetMe'
import SourcesPage from 'src/sources/components/SourcesPage'
// Actions
import {disablePresentationMode} from 'src/shared/actions/app'
@ -82,7 +82,7 @@ class Root extends PureComponent {
<Route component={GetOrganizations}>
<Route component={App}>
<Route path="/" component={GetSources}>
<Route path="/" component={SetSource}>
<Route path="/" component={SetActiveSource}>
<Route
path="dashboards/:dashboardID"
component={DashboardPage}
@ -98,26 +98,14 @@ class Root extends PureComponent {
/>
<Route path="tasks/new" component={TaskPage} />
<Route path="tasks/:id" component={TaskEditPage} />
<Route path="sources/new" component={SourcePage} />
<Route
path="data-explorer"
component={DataExplorerPage}
/>
<Route path="dashboards" component={DashboardsPage} />
<Route
path="manage-sources"
component={ManageSources}
/>
<Route
path="manage-sources/new"
component={SourcePage}
/>
<Route
path="manage-sources/:id/edit"
component={SourcePage}
/>
<Route path="user_profile" component={UserPage} />
<Route path="logs" component={LogsPage} />
<Route path="sources" component={SourcesPage} />
</Route>
</Route>
</Route>

View File

@ -14,7 +14,7 @@ import {
createView as createViewAJAX,
updateView as updateViewAJAX,
} from 'src/dashboards/apis/v2/view'
import {getSource} from 'src/sources/apis/v2'
import {readSource} from 'src/sources/apis'
import {getBuckets} from 'src/shared/apis/v2/buckets'
import {executeQueryAsync} from 'src/logs/api/v2'
@ -263,7 +263,7 @@ export const populateBucketsAsync = (
export const getSourceAndPopulateBucketsAsync = (sourceURL: string) => async (
dispatch
): Promise<void> => {
const source = await getSource(sourceURL)
const source = await readSource(sourceURL)
const bucketsLink = getDeep<string | null>(source, 'links.buckets', null)

View File

@ -14,7 +14,6 @@ import LogsTable from 'src/logs/components/logs_table/LogsTable'
// Actions
import * as logActions from 'src/logs/actions'
import {getSourcesAsync} from 'src/shared/actions/sources'
import {notify as notifyAction} from 'src/shared/actions/notifications'
// Utils
@ -24,6 +23,7 @@ import {
applyChangesToTableData,
isEmptyInfiniteData,
} from 'src/logs/utils/table'
import {getSources} from 'src/sources/selectors'
// Constants
import {NOW, DEFAULT_TAIL_CHUNK_DURATION_MS} from 'src/logs/constants'
@ -57,7 +57,6 @@ interface TableConfigStateProps {
interface DispatchTableConfigProps {
notify: typeof notifyAction
getConfig: typeof logActions.getLogConfigAsync
getSources: typeof getSourcesAsync
addFilter: typeof logActions.addFilter // TODO: update addFilters
setConfig: typeof logActions.setConfig
updateConfig: typeof logActions.updateLogConfigAsync
@ -120,7 +119,6 @@ class LogsPage extends Component<Props, State> {
public async componentDidMount() {
try {
await this.props.getSources()
await this.setCurrentSource()
await this.props.getConfig(this.configLink)
@ -558,37 +556,41 @@ class LogsPage extends Component<Props, State> {
}
}
const mstp = ({
sources,
links,
logs: {
currentSource,
currentBuckets,
currentBucket,
const mstp = (state: AppState): StateProps => {
const {
links,
logs: {
currentSource,
currentBuckets,
currentBucket,
filters,
logConfig,
searchStatus,
tableInfiniteData,
nextTailLowerBound,
currentTailUpperBound,
},
} = state
const sources = getSources(state)
return {
links,
sources,
filters,
logConfig,
searchStatus,
currentSource,
currentBucket,
currentBuckets,
tableInfiniteData,
nextTailLowerBound,
currentTailUpperBound,
},
}: AppState): StateProps => ({
links,
sources,
filters,
logConfig,
searchStatus,
currentSource,
currentBucket,
currentBuckets,
tableInfiniteData,
nextTailLowerBound,
currentTailUpperBound,
})
}
}
const mdtp: DispatchProps = {
notify: notifyAction,
getSources: getSourcesAsync,
addFilter: logActions.addFilter,
updateConfig: logActions.updateLogConfigAsync,
createConfig: logActions.createLogConfigAsync,

View File

@ -1,5 +1,5 @@
// Libraries
import React, {Component} from 'react'
import React, {Component, ReactNode} from 'react'
import classnames from 'classnames'
// Components
@ -9,7 +9,7 @@ import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar
import {ErrorHandling} from 'src/shared/decorators/errors'
interface Props {
children: JSX.Element[] | JSX.Element
children: JSX.Element[] | JSX.Element | ReactNode
fullWidth: boolean
scrollable: boolean
}
@ -36,7 +36,7 @@ class PageContents extends Component<Props> {
return classnames('page-contents', {'full-width': fullWidth})
}
private get children(): JSX.Element | JSX.Element[] {
private get children(): JSX.Element | JSX.Element[] | ReactNode {
const {fullWidth, children} = this.props
if (fullWidth) {

View File

@ -7,8 +7,11 @@ import _ from 'lodash'
// Components
import NavMenu from 'src/pageLayout/components/NavMenu'
// Utils
import {getSources} from 'src/sources/selectors'
// Types
import {Source} from 'src/types/v2'
import {Source, AppState} from 'src/types/v2'
import {IconFont} from 'src/clockface'
// Styles
@ -62,7 +65,7 @@ class SideNav extends PureComponent<Props> {
{
type: NavItemType.Icon,
title: 'Status',
link: `/${this.sourceParam}`,
link: '/',
icon: IconFont.Cubouniform,
location: location.pathname,
highlightWhen: ['status'],
@ -70,7 +73,7 @@ class SideNav extends PureComponent<Props> {
{
type: NavItemType.Icon,
title: 'Data Explorer',
link: `/data-explorer/${this.sourceParam}`,
link: '/data-explorer',
icon: IconFont.Capacitor,
location: location.pathname,
highlightWhen: ['data-explorer'],
@ -78,7 +81,7 @@ class SideNav extends PureComponent<Props> {
{
type: NavItemType.Icon,
title: 'Dashboards',
link: `/dashboards/${this.sourceParam}`,
link: '/dashboards',
icon: IconFont.DashJ,
location: location.pathname,
highlightWhen: ['dashboards'],
@ -86,7 +89,7 @@ class SideNav extends PureComponent<Props> {
{
type: NavItemType.Icon,
title: 'Logs',
link: `/logs/${this.sourceParam}`,
link: '/logs',
icon: IconFont.Wood,
location: location.pathname,
highlightWhen: ['logs'],
@ -94,7 +97,7 @@ class SideNav extends PureComponent<Props> {
{
type: NavItemType.Icon,
title: 'Tasks',
link: `/tasks/${this.sourceParam}`,
link: '/tasks',
icon: IconFont.Alerts,
location: location.pathname,
highlightWhen: ['tasks'],
@ -102,49 +105,36 @@ class SideNav extends PureComponent<Props> {
{
type: NavItemType.Icon,
title: 'Organizations',
link: `/organizations/${this.sourceParam}`,
link: '/organizations',
icon: IconFont.Group,
location: location.pathname,
highlightWhen: ['organizations'],
},
{
type: NavItemType.Icon,
title: 'Configuration',
link: `/manage-sources/${this.sourceParam}`,
title: 'Sources',
link: '/sources',
icon: IconFont.Wrench,
location: location.pathname,
highlightWhen: ['manage-sources'],
highlightWhen: ['sources'],
},
{
type: NavItemType.Avatar,
title: 'My Profile',
link: `/user_profile/${this.sourceParam}`,
link: '/user_profile',
image: LeroyJenkins.avatar,
location: location.pathname,
highlightWhen: ['user_profile'],
},
]
}
private get sourceParam(): string {
const {location, sources = []} = this.props
const {query} = location
const defaultSource = sources.find(s => s.default)
const id = query.sourceID || _.get(defaultSource, 'id', 0)
return `?sourceID=${id}`
}
}
const mapStateToProps = ({
sources,
app: {
ephemeral: {inPresentationMode},
},
}) => ({
sources,
isHidden: inPresentationMode,
})
const mstp = (state: AppState) => {
const isHidden = state.app.ephemeral.inPresentationMode
const sources = getSources(state)
export default connect(mapStateToProps)(withRouter(SideNav))
return {sources, isHidden}
}
export default connect(mstp)(withRouter(SideNav))

View File

@ -1,90 +0,0 @@
import {deleteSource, getSources as getSourcesAJAX} from 'src/sources/apis/v2'
import {notify} from './notifications'
import {HTTP_NOT_FOUND} from 'src/shared/constants'
import {serverError} from 'src/shared/copy/notifications'
import {Source} from 'src/types/v2'
export type Action = ActionLoadSources | ActionUpdateSource | ActionAddSource
// Load Sources
export type LoadSources = (sources: Source[]) => ActionLoadSources
export interface ActionLoadSources {
type: 'LOAD_SOURCES'
payload: {
sources: Source[]
}
}
export const loadSources = (sources: Source[]): ActionLoadSources => ({
type: 'LOAD_SOURCES',
payload: {
sources,
},
})
export type UpdateSource = (source: Source) => ActionUpdateSource
export interface ActionUpdateSource {
type: 'SOURCE_UPDATED'
payload: {
source: Source
}
}
export const updateSource = (source: Source): ActionUpdateSource => ({
type: 'SOURCE_UPDATED',
payload: {
source,
},
})
export type AddSource = (source: Source) => ActionAddSource
export interface ActionAddSource {
type: 'SOURCE_ADDED'
payload: {
source: Source
}
}
export const addSource = (source: Source): ActionAddSource => ({
type: 'SOURCE_ADDED',
payload: {
source,
},
})
export type RemoveAndLoadSources = (
source: Source
) => (dispatch) => Promise<void>
// Async action creators
export const removeAndLoadSources = (source: Source) => async (
dispatch
): Promise<void> => {
try {
try {
await deleteSource(source)
} catch (err) {
// A 404 means that either a concurrent write occurred or the source
// passed to this action creator doesn't exist (or is undefined)
if (err.status !== HTTP_NOT_FOUND) {
throw err
}
}
const newSources = await getSourcesAJAX()
dispatch(loadSources(newSources))
} catch (err) {
dispatch(notify(serverError))
}
}
export const getSourcesAsync = () => async (dispatch): Promise<void> => {
try {
const sources = await getSourcesAJAX()
dispatch(loadSources(sources))
} catch (error) {
dispatch(notify(serverError))
}
}

View File

@ -1,30 +0,0 @@
import {Source} from 'src/types/v2'
export enum ActionTypes {
SetSource = 'SET_SOURCE',
ResetSource = 'RESET_SOURCE',
}
interface SetSource {
type: ActionTypes.SetSource
payload: {
source: Source
}
}
interface ResetSource {
type: ActionTypes.ResetSource
}
export type Actions = SetSource | ResetSource
export const setSource = (source: Source) => ({
type: ActionTypes.SetSource,
payload: {
source,
},
})
export const resetSource = () => ({
type: ActionTypes.ResetSource,
})

View File

@ -12,6 +12,9 @@ import RefreshingViewSwitcher from 'src/shared/components/RefreshingViewSwitcher
// Constants
import {emptyGraphCopy} from 'src/shared/copy/cell'
// Utils
import {getActiveSource} from 'src/sources/selectors'
// Types
import {TimeRange} from 'src/types'
import {AppState} from 'src/types/v2'
@ -102,12 +105,10 @@ class RefreshingView extends PureComponent<Props> {
}
}
const mstp = ({source}: AppState): StateProps => {
const link = source.links.query
const mstp = (state: AppState): StateProps => {
const link = getActiveSource(state).links.query
return {
link,
}
return {link}
}
const mdtp = {}

View File

@ -8,6 +8,7 @@ import {ErrorHandling} from 'src/shared/decorators/errors'
interface Props {
disabled?: boolean
children: JSX.Element[] | JSX.Element
customClass?: string
}
@ErrorHandling
@ -23,9 +24,12 @@ class IndexListRow extends Component<Props> {
}
private get className(): string {
const {disabled} = this.props
const {disabled, customClass} = this.props
return classnames('index-list--row', {'index-list--row-disabled': disabled})
return classnames('index-list--row', {
'index-list--row-disabled': disabled,
[customClass]: !!customClass,
})
}
}

View File

@ -6,11 +6,12 @@ import MockChild from 'mocks/MockChild'
import {source} from 'mocks/dummy'
jest.mock('src/sources/apis', () => require('mocks/sources/apis'))
const getSources = jest.fn(() => Promise.resolve)
const onReadSources = jest.fn(() => Promise.resolve())
const setup = (override?) => {
const props = {
getSources,
onReadSources,
sources: [source],
router: {},
location: {

View File

@ -4,12 +4,12 @@ import {connect} from 'react-redux'
import {RemoteDataState} from 'src/types'
import {getSourcesAsync} from 'src/shared/actions/sources'
import {readSources} from 'src/sources/actions'
import {ErrorHandling} from 'src/shared/decorators/errors'
interface Props {
children: React.ReactElement<any>
getSources: typeof getSourcesAsync
onReadSources: typeof readSources
}
interface State {
@ -27,7 +27,8 @@ export class GetSources extends PureComponent<Props, State> {
}
public async componentDidMount() {
await this.props.getSources()
await this.props.onReadSources()
this.setState({ready: RemoteDataState.Done})
}
@ -41,7 +42,7 @@ export class GetSources extends PureComponent<Props, State> {
}
const mdtp = {
getSources: getSourcesAsync,
onReadSources: readSources,
}
export default connect(

View File

@ -0,0 +1,94 @@
// Libraries
import React, {PureComponent} from 'react'
import {connect} from 'react-redux'
import {get} from 'lodash'
// Actions
import {setActiveSource} from 'src/sources/actions'
// Utils
import {getSources, getActiveSource} from 'src/sources/selectors'
import {readQueryParams, updateQueryParams} from 'src/shared/utils/queryParams'
// Types
import {Source, AppState} from 'src/types/v2'
interface PassedInProps {
children: React.ReactElement<any>
}
interface ConnectStateProps {
activeSourceID: string
sources: Source[]
source: Source
}
interface ConnectDispatchProps {
onSetActiveSource: typeof setActiveSource
}
type Props = ConnectStateProps & ConnectDispatchProps & PassedInProps
class SetActiveSource extends PureComponent<Props> {
public componentDidMount() {
this.resolveActiveSource()
}
public render() {
const {source} = this.props
if (!source) {
return null
}
return this.props.children
}
public componentDidUpdate() {
this.resolveActiveSource()
}
private resolveActiveSource() {
const {sources, activeSourceID, onSetActiveSource} = this.props
const defaultSourceID = get(sources.find(s => s.default), 'id')
const querySourceID = readQueryParams().sourceID
let resolvedSourceID
if (sources.find(s => s.id === activeSourceID)) {
resolvedSourceID = activeSourceID
} else if (sources.find(s => s.id === querySourceID)) {
resolvedSourceID = querySourceID
} else if (defaultSourceID) {
resolvedSourceID = defaultSourceID
} else if (sources.length) {
resolvedSourceID = sources[0]
} else {
throw new Error('no source exists')
}
if (activeSourceID !== resolvedSourceID) {
onSetActiveSource(resolvedSourceID)
}
if (querySourceID !== resolvedSourceID) {
updateQueryParams({sourceID: resolvedSourceID})
}
}
}
const mstp = (state: AppState) => ({
source: getActiveSource(state),
sources: getSources(state),
activeSourceID: state.sources.activeSourceID,
})
const mdtp = {
onSetActiveSource: setActiveSource,
}
export default connect<ConnectStateProps, ConnectDispatchProps, PassedInProps>(
mstp,
mdtp
)(SetActiveSource)

View File

@ -1,130 +0,0 @@
import React, {PureComponent} from 'react'
import {connect} from 'react-redux'
import {withRouter, InjectedRouter, WithRouterProps} from 'react-router'
import {Location} from 'history'
import {Source} from 'src/types/v2'
import {Links} from 'src/types/v2/links'
import {notify} from 'src/shared/actions/notifications'
import {Notification, NotificationFunc} from 'src/types'
import {setSource, resetSource} from 'src/shared/actions/v2/source'
import {getSourceHealth} from 'src/sources/apis/v2'
import * as copy from 'src/shared/copy/notifications'
interface PassedInProps extends WithRouterProps {
router: InjectedRouter
children: React.ReactElement<any>
location: Location
}
interface ConnectStateProps {
sources: Source[]
links: Links
}
interface ConnectDispatchProps {
setSource: typeof setSource
resetSource: typeof resetSource
notify: (message: Notification | NotificationFunc) => void
}
type Props = ConnectStateProps & ConnectDispatchProps & PassedInProps
export const SourceContext = React.createContext({})
class SetSource extends PureComponent<Props> {
public render() {
return this.props.children && React.cloneElement(this.props.children)
}
public componentDidMount() {
const source = this.source
if (source) {
this.props.setSource(source)
}
}
public async componentDidUpdate() {
const {router, sources} = this.props
const source = this.source
const defaultSource = sources.find(s => s.default === true)
if (this.isRoot) {
return router.push(`${this.rootPath}`)
}
if (!source) {
if (defaultSource) {
return router.push(`${this.path}?sourceID=${defaultSource.id}`)
}
if (sources[0]) {
return router.push(`${this.path}?sourceID=${sources[0].id}`)
}
return router.push(`/sources/new?redirectPath=${this.path}`)
}
try {
await getSourceHealth(source.links.health)
this.props.setSource(source)
} catch (error) {
this.props.notify(copy.sourceNoLongerAvailable(source.name))
this.props.resetSource()
}
}
private get path(): string {
const {location} = this.props
if (this.isRoot) {
return this.rootPath
}
return `${location.pathname}`
}
private get rootPath(): string {
const {links, location} = this.props
if (links.defaultDashboard) {
const split = links.defaultDashboard.split('/')
const id = split[split.length - 1]
return `/dashboards/${id}${location.search}`
}
return `/dashboards`
}
private get isRoot(): boolean {
const {
location: {pathname},
} = this.props
return pathname === '' || pathname === '/'
}
private get source(): Source {
const {location, sources} = this.props
return sources.find(s => s.id === location.query.sourceID)
}
}
const mstp = ({sources, links}) => ({
links,
sources,
})
const mdtp = {
setSource,
resetSource,
notify,
}
export default connect<ConnectStateProps, ConnectDispatchProps, PassedInProps>(
mstp,
mdtp
)(withRouter(SetSource))

View File

@ -137,13 +137,10 @@ export const sourceCreationSucceeded = (sourceName: string): Notification => ({
message: `Connected to InfluxDB ${sourceName} successfully.`,
})
export const sourceCreationFailed = (
sourceName: string,
errorMessage: string
): Notification => ({
export const sourceCreationFailed = (errorMessage: string): Notification => ({
...defaultErrorNotification,
icon: 'server2',
message: `Unable to connect to InfluxDB ${sourceName}: ${errorMessage}`,
message: `Unable to create source: ${errorMessage}`,
})
export const sourceUpdated = (sourceName: string): Notification => ({

View File

@ -1,27 +0,0 @@
import {Source} from 'src/types/v2'
import {Actions, ActionTypes} from 'src/shared/actions/v2/source'
export type SourceState = Source
const defaultState: SourceState = {
name: '',
id: '',
type: '',
url: '',
insecureSkipVerify: false,
default: false,
telegraf: '',
links: null,
}
export default (state = defaultState, action: Actions): SourceState => {
switch (action.type) {
case ActionTypes.ResetSource:
return {...defaultState}
case ActionTypes.SetSource:
return {...action.payload.source}
default:
return state
}
}

View File

@ -0,0 +1,27 @@
import {browserHistory} from 'react-router'
import qs from 'qs'
import {pickBy} from 'lodash'
export const readQueryParams = (): {[key: string]: any} => {
return qs.parse(window.location.search, {ignoreQueryPrefix: true})
}
/*
Given an object of query parameter keys and values, updates any corresponding
query parameters in the URL to match. If the supplied object has a null value
for a key, that query parameter will be removed from the URL altogether.
*/
export const updateQueryParams = (updatedQueryParams: object): void => {
const currentQueryString = window.location.search
const newQueryParams = pickBy(
{
...qs.parse(currentQueryString, {ignoreQueryPrefix: true}),
...updatedQueryParams,
},
v => !!v
)
const newQueryString = qs.stringify(newQueryParams)
browserHistory.replace(`${window.location.pathname}?${newQueryString}`)
}

View File

@ -0,0 +1,112 @@
// Libraries
import {Dispatch} from 'redux'
// APIs
import {
readSources as readSourcesAJAX,
createSource as createSourceAJAX,
updateSource as updateSourceAJAX,
deleteSource as deleteSourceAJAX,
} from 'src/sources/apis'
// Types
import {Source, GetState} from 'src/types/v2'
export type Action =
| SetActiveSourceAction
| SetSourcesAction
| SetSourceAction
| RemoveSourceAction
interface SetActiveSourceAction {
type: 'SET_ACTIVE_SOURCE'
payload: {
activeSourceID: string | null
}
}
export const setActiveSource = (
activeSourceID: string | null
): SetActiveSourceAction => ({
type: 'SET_ACTIVE_SOURCE',
payload: {activeSourceID},
})
interface SetSourcesAction {
type: 'SET_SOURCES'
payload: {
sources: Source[]
}
}
export const setSources = (sources: Source[]): SetSourcesAction => ({
type: 'SET_SOURCES',
payload: {sources},
})
interface SetSourceAction {
type: 'SET_SOURCE'
payload: {
source: Source
}
}
export const setSource = (source: Source): SetSourceAction => ({
type: 'SET_SOURCE',
payload: {source},
})
interface RemoveSourceAction {
type: 'REMOVE_SOURCE'
payload: {
sourceID: string
}
}
export const removeSource = (sourceID: string): RemoveSourceAction => ({
type: 'REMOVE_SOURCE',
payload: {sourceID},
})
export const readSources = () => async (
dispatch: Dispatch<Action>,
getState: GetState
) => {
const sourcesLink = getState().links.sources
const sources = await readSourcesAJAX(sourcesLink)
dispatch(setSources(sources))
}
export const createSource = (attrs: Partial<Source>) => async (
dispatch: Dispatch<Action>,
getState: GetState
) => {
const sourcesLink = getState().links.sources
const source = await createSourceAJAX(sourcesLink, attrs)
dispatch(setSource(source))
}
export const updateSource = (source: Source) => async (
dispatch: Dispatch<Action>
) => {
const updatedSource = await updateSourceAJAX(source)
dispatch(setSource(updatedSource))
}
export const deleteSource = (sourceID: string) => async (
dispatch: Dispatch<Action>,
getState: GetState
) => {
const source = getState().sources.sources[sourceID]
if (!source) {
throw new Error(`no source with ID "${sourceID}" exists`)
}
await deleteSourceAJAX(source)
dispatch(removeSource(sourceID))
}

View File

@ -1,6 +1,53 @@
import AJAX from 'src/utils/ajax'
import {Source} from 'src/types/v2'
export const getSourceHealth = async (url: string) => {
export const readSources = async (url): Promise<Source[]> => {
const {data} = await AJAX({url})
return data.sources
}
export const readSource = async (url: string): Promise<Source> => {
const {data: source} = await AJAX({
url,
})
return source
}
export const createSource = async (
url: string,
attributes: Partial<Source>
): Promise<Source> => {
const {data: source} = await AJAX({
url,
method: 'POST',
data: attributes,
})
return source
}
export const updateSource = async (
newSource: Partial<Source>
): Promise<Source> => {
const {data: source} = await AJAX({
url: newSource.links.self,
method: 'PATCH',
data: newSource,
})
return source
}
export function deleteSource(source) {
return AJAX({
url: source.links.self,
method: 'DELETE',
})
}
export const getSourceHealth = async (url: string): Promise<void> => {
try {
await AJAX({url})
} catch (error) {

View File

@ -1,75 +0,0 @@
import AJAX from 'src/utils/ajax'
import {Source} from 'src/types/v2'
export const getSources = async (): Promise<Source[]> => {
try {
const {data} = await AJAX({
url: '/api/v2/sources',
})
return data.sources
} catch (error) {
throw error
}
}
export const getSource = async (url: string): Promise<Source> => {
try {
const {data: source} = await AJAX({
url,
})
return source
} catch (error) {
throw error
}
}
export const createSource = async (
url: string,
attributes: Partial<Source>
): Promise<Source> => {
try {
const {data: source} = await AJAX({
url,
method: 'POST',
data: attributes,
})
return source
} catch (error) {
throw error
}
}
export const updateSource = async (
newSource: Partial<Source>
): Promise<Source> => {
try {
const {data: source} = await AJAX({
url: newSource.links.self,
method: 'PATCH',
data: newSource,
})
return source
} catch (error) {
throw error
}
}
export function deleteSource(source) {
return AJAX({
url: source.links.self,
method: 'DELETE',
})
}
export const getSourceHealth = async (url: string): Promise<void> => {
try {
await AJAX({url})
} catch (error) {
console.error(`Unable to contact source ${url}`, error)
throw error
}
}

View File

@ -1,59 +0,0 @@
import React, {PureComponent} from 'react'
import {Link} from 'react-router'
import {stripPrefix} from 'src/utils/basepath'
import {Source} from 'src/types/v2'
interface Props {
source: Source
currentSource: Source
}
class ConnectionLink extends PureComponent<Props> {
public render() {
const {source} = this.props
return (
<h5 className="margin-zero">
<Link
to={`${stripPrefix(location.pathname)}/${source.id}/edit?${
this.sourceParam
}`}
className={this.className}
>
<strong>{source.name}</strong>
{this.default}
</Link>
</h5>
)
}
private get sourceParam(): string {
const {currentSource} = this.props
return `sourceID=${currentSource.id}`
}
private get className(): string {
if (this.isCurrentSource) {
return 'link-success'
}
return ''
}
private get default(): string {
const {source} = this.props
if (source.default) {
return ' (Default)'
}
return ''
}
private get isCurrentSource(): boolean {
const {source, currentSource} = this.props
return source.id === currentSource.id
}
}
export default ConnectionLink

View File

@ -0,0 +1,11 @@
.create-source-overlay--heading-buttons {
button {
margin-left: 5px;
}
}
.create-source-overlay {
.form--element {
margin-bottom: 15px;
}
}

View File

@ -0,0 +1,165 @@
// Libraries
import React, {PureComponent, ChangeEvent} from 'react'
import {connect} from 'react-redux'
// Components
import {
OverlayBody,
OverlayHeading,
OverlayContainer,
Button,
ComponentColor,
ComponentStatus,
Form,
Input,
Radio,
} from 'src/clockface'
// Actions
import {createSource} from 'src/sources/actions'
import {notify} from 'src/shared/actions/notifications'
// Utils
import {sourceCreationFailed} from 'src/shared/copy/notifications'
// Styles
import 'src/sources/components/CreateSourceOverlay.scss'
// Types
import {Source} from 'src/types/v2'
import {RemoteDataState} from 'src/types'
interface DispatchProps {
onCreateSource: typeof createSource
onNotify: typeof notify
}
interface OwnProps {
onHide: () => void
}
type Props = DispatchProps & OwnProps
interface State {
draftSource: Partial<Source>
creationStatus: RemoteDataState
}
class CreateSourceOverlay extends PureComponent<Props, State> {
public state: State = {
draftSource: {
name: '',
type: 'v1',
url: '',
},
creationStatus: RemoteDataState.NotStarted,
}
public render() {
const {onHide} = this.props
const {draftSource} = this.state
return (
<div className="create-source-overlay">
<OverlayContainer>
<OverlayHeading title="Create Source">
<div className="create-source-overlay--heading-buttons">
<Button text="Cancel" onClick={onHide} />
<Button
text="Save"
color={ComponentColor.Success}
status={this.saveButtonStatus}
onClick={this.handleSave}
/>
</div>
</OverlayHeading>
<OverlayBody>
<Form>
<Form.Element label="Name">
<Input
name="name"
autoFocus={true}
value={draftSource.name}
onChange={this.handleInputChange}
/>
</Form.Element>
<Form.Element label="URL">
<Input
name="url"
autoFocus={true}
value={draftSource.url}
onChange={this.handleInputChange}
/>
</Form.Element>
<Form.Element label="Type">
<Radio>
<Radio.Button
active={draftSource.type === 'v1'}
onClick={this.handleChangeType}
value="v1"
>
v1
</Radio.Button>
<Radio.Button
active={draftSource.type === 'v2'}
onClick={this.handleChangeType}
value="v2"
>
v2
</Radio.Button>
</Radio>
</Form.Element>
</Form>
</OverlayBody>
</OverlayContainer>
</div>
)
}
private get saveButtonStatus(): ComponentStatus {
return ComponentStatus.Default
}
private handleSave = async () => {
const {onCreateSource, onNotify, onHide} = this.props
const {draftSource} = this.state
this.setState({creationStatus: RemoteDataState.Loading})
try {
await onCreateSource(draftSource)
onHide()
} catch (error) {
this.setState({creationStatus: RemoteDataState.Error})
onNotify(sourceCreationFailed(error.toString()))
}
}
private handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const draftSource = {
...this.state.draftSource,
[e.target.name]: e.target.value,
}
this.setState({draftSource})
}
private handleChangeType = (type: 'v1' | 'v2') => {
const draftSource = {
...this.state.draftSource,
type,
}
this.setState({draftSource})
}
}
const mdtp = {
onCreateSource: createSource,
onNotify: notify,
}
export default connect<{}, DispatchProps, OwnProps>(
null,
mdtp
)(CreateSourceOverlay)

View File

@ -0,0 +1,53 @@
// Libraries
import React, {PureComponent} from 'react'
// Components
import {
Button,
ComponentColor,
ComponentSize,
ComponentStatus,
} from 'src/clockface'
// Types
import {RemoteDataState} from 'src/types'
interface Props {
onClick: () => Promise<void>
}
interface State {
status: RemoteDataState
}
class DeleteSourceButton extends PureComponent<Props, State> {
public state: State = {
status: RemoteDataState.NotStarted,
}
public render() {
const {status} = this.state
const buttonStatus =
status === RemoteDataState.Loading
? ComponentStatus.Loading
: ComponentStatus.Default
return (
<Button
text="Delete"
color={ComponentColor.Danger}
size={ComponentSize.ExtraSmall}
status={buttonStatus}
onClick={this.handleClick}
/>
)
}
private handleClick = async () => {
this.setState({status: RemoteDataState.Loading})
this.props.onClick()
}
}
export default DeleteSourceButton

View File

@ -1,51 +0,0 @@
import React, {PureComponent} from 'react'
import InfluxTableHead from 'src/sources/components/InfluxTableHead'
import InfluxTableHeader from 'src/sources/components/InfluxTableHeader'
import InfluxTableRow from 'src/sources/components/InfluxTableRow'
import {Source} from 'src/types/v2'
import {ErrorHandling} from 'src/shared/decorators/errors'
interface Props {
source: Source
sources: Source[]
onDeleteSource: (source: Source) => void
}
@ErrorHandling
class InfluxTable extends PureComponent<Props> {
public render() {
const {source, sources, onDeleteSource} = this.props
return (
<div className="row">
<div className="col-md-12">
<div className="panel">
<InfluxTableHeader source={source} />
<div className="panel-body">
<table className="table v-center margin-bottom-zero table-highlight">
<InfluxTableHead />
<tbody>
{sources.map(s => {
return (
<InfluxTableRow
key={s.id}
source={s}
currentSource={source}
onDeleteSource={onDeleteSource}
/>
)
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
}
export default InfluxTable

View File

@ -1,15 +0,0 @@
import React, {SFC} from 'react'
const InfluxTableHead: SFC = (): JSX.Element => {
return (
<thead>
<tr>
<th className="source-table--connect-col" />
<th>InfluxDB Connection</th>
<th className="text-right" />
</tr>
</thead>
)
}
export default InfluxTableHead

View File

@ -1,33 +0,0 @@
import React, {PureComponent} from 'react'
import {Link} from 'react-router'
import {Source} from 'src/types/v2'
import {ErrorHandling} from 'src/shared/decorators/errors'
interface Props {
source: Source
}
@ErrorHandling
class InfluxTableHeader extends PureComponent<Props> {
public render() {
const {source} = this.props
return (
<div className="panel-heading">
<h2 className="panel-title">
<span>Connections</span>
</h2>
<Link
to={`/sources/${source.id}/manage-sources/new`}
className="btn btn-sm btn-primary"
>
<span className="icon plus" /> Add Connection
</Link>
</div>
)
}
}
export default InfluxTableHeader

View File

@ -1,79 +0,0 @@
import React, {PureComponent} from 'react'
import {Link} from 'react-router'
import ConfirmButton from 'src/shared/components/ConfirmButton'
import ConnectionLink from 'src/sources/components/ConnectionLink'
import {Source} from 'src/types/v2'
import {ErrorHandling} from 'src/shared/decorators/errors'
interface Props {
source: Source
currentSource: Source
onDeleteSource: (source: Source) => void
}
@ErrorHandling
class InfluxTableRow extends PureComponent<Props> {
public render() {
const {source, currentSource} = this.props
return (
<tr className={this.className}>
<td>{this.connectButton}</td>
<td>
<ConnectionLink source={source} currentSource={currentSource} />
<span>{source.url}</span>
</td>
<td className="text-right">
<ConfirmButton
type="btn-danger"
size="btn-xs"
text="Delete Connection"
confirmAction={this.handleDeleteSource}
customClass="delete-source table--show-on-row-hover"
/>
</td>
</tr>
)
}
private handleDeleteSource = (): void => {
this.props.onDeleteSource(this.props.source)
}
private get connectButton(): JSX.Element {
const {source} = this.props
if (this.isCurrentSource) {
return (
<div className="btn btn-success btn-xs source-table--connect">
Connected
</div>
)
}
return (
<Link
className="btn btn-default btn-xs source-table--connect"
to={`/manage-sources?sourceID=${source.id}`}
>
Connect
</Link>
)
}
private get className(): string {
if (this.isCurrentSource) {
return 'highlight'
}
return ''
}
private get isCurrentSource(): boolean {
const {source, currentSource} = this.props
return source.id === currentSource.id
}
}
export default InfluxTableRow

View File

@ -1,42 +0,0 @@
import React from 'react'
import {shallow} from 'enzyme'
import {SourceForm} from 'src/sources/components/SourceForm'
const setup = (override = {}) => {
const noop = () => {}
const props = {
source: {
url: '',
name: '',
username: '',
password: '',
telegraf: '',
insecureSkipVerify: false,
default: false,
metaUrl: '',
},
editMode: false,
onSubmit: noop,
onInputChange: noop,
onBlurSourceURL: noop,
isUsingAuth: false,
gotoPurgatory: noop,
isInitialSource: false,
...override,
}
const wrapper = shallow(<SourceForm {...props} />)
return {wrapper, props}
}
describe('Sources.Components.SourceForm', () => {
describe('rendering', () => {
it('renders inputs', () => {
const {wrapper} = setup()
const inputs = wrapper.find('input')
expect(inputs.exists()).toBe(true)
})
})
})

View File

@ -1,180 +0,0 @@
import React, {PureComponent, FocusEvent, MouseEvent, ChangeEvent} from 'react'
import classnames from 'classnames'
import _ from 'lodash'
import {insecureSkipVerifyText} from 'src/shared/copy/tooltipText'
import {Source} from 'src/types/v2'
interface Props {
source: Partial<Source>
editMode: boolean
isInitialSource: boolean
onSubmit: (e: MouseEvent<HTMLFormElement>) => void
onInputChange: (e: ChangeEvent<HTMLInputElement>) => void
onBlurSourceURL: (e: FocusEvent<HTMLInputElement>) => void
}
export class SourceForm extends PureComponent<Props> {
public render() {
const {
source,
onSubmit,
onInputChange,
onBlurSourceURL,
isInitialSource,
} = this.props
return (
<div className="panel-body">
{isInitialSource}
<form onSubmit={onSubmit}>
<div className="form-group col-xs-12 col-sm-6">
<label htmlFor="connect-string">Connection String</label>
<input
type="text"
name="url"
className="form-control"
id="connect-string"
placeholder="Address of InfluxDB"
onChange={onInputChange}
value={source.url}
onBlur={onBlurSourceURL}
required={true}
/>
</div>
<div className="form-group col-xs-12 col-sm-6">
<label htmlFor="name">Name</label>
<input
type="text"
name="name"
className="form-control"
id="name"
placeholder="Name this source"
onChange={onInputChange}
value={source.name}
required={true}
/>
</div>
<div className="form-group col-xs-12 col-sm-6">
<label htmlFor="username">Username</label>
<input
type="text"
name="username"
className="form-control"
id="username"
onChange={onInputChange}
value={source.username}
/>
</div>
<div className="form-group col-xs-12 col-sm-6">
<label htmlFor="password">Password</label>
<input
type="password"
name="password"
className="form-control"
id="password"
onChange={onInputChange}
value={source.password}
/>
</div>
{this.isEnterprise && (
<div className="form-group col-xs-12">
<label htmlFor="meta-url">Meta Service Connection URL</label>
<input
type="text"
name="metaUrl"
className="form-control"
id="meta-url"
placeholder="http://localhost:8091"
onChange={onInputChange}
value={source.metaUrl}
/>
</div>
)}
<div className="form-group col-xs-12 col-sm-6">
<label htmlFor="telegraf">Telegraf Database</label>
<input
type="text"
name="telegraf"
className="form-control"
id="telegraf"
onChange={onInputChange}
value={source.telegraf}
/>
</div>
<div className="form-group col-xs-12">
<div className="form-control-static">
<input
type="checkbox"
id="defaultConnectionCheckbox"
name="default"
checked={source.default}
onChange={onInputChange}
/>
<label htmlFor="defaultConnectionCheckbox">
Make this the default connection
</label>
</div>
</div>
{this.isHTTPS && (
<div className="form-group col-xs-12">
<div className="form-control-static">
<input
type="checkbox"
id="insecureSkipVerifyCheckbox"
name="insecureSkipVerify"
checked={source.insecureSkipVerify}
onChange={onInputChange}
/>
<label htmlFor="insecureSkipVerifyCheckbox">Unsafe SSL</label>
</div>
<label className="form-helper">{insecureSkipVerifyText}</label>
</div>
)}
<div className="form-group form-group-submit text-center col-xs-12 col-sm-6 col-sm-offset-3">
<button className={this.submitClass} type="submit">
<span className={this.submitIconClass} />
{this.submitText}
</button>
<br />
</div>
</form>
</div>
)
}
private get submitText(): string {
const {editMode} = this.props
if (editMode) {
return 'Save Changes'
}
return 'Add Connection'
}
private get submitIconClass(): string {
const {editMode} = this.props
return `icon ${editMode ? 'checkmark' : 'plus'}`
}
private get submitClass(): string {
const {editMode} = this.props
return classnames('btn btn-block', {
'btn-primary': editMode,
'btn-success': !editMode,
})
}
private get isEnterprise(): boolean {
const {source} = this.props
return _.get(source, 'type', '').includes('enterprise')
}
private get isHTTPS(): boolean {
const {source} = this.props
return _.get(source, 'url', '').startsWith('https')
}
}
export default SourceForm

View File

@ -0,0 +1,61 @@
// Libraries
import React, {SFC} from 'react'
import {connect} from 'react-redux'
// Components
import IndexList from 'src/shared/components/index_views/IndexList'
import {Alignment} from 'src/clockface'
import SourcesListRow from 'src/sources/components/SourcesListRow'
// Utils
import {getSources} from 'src/sources/selectors'
// Styles
import './SourcesList.scss'
// Types
import {AppState, Source} from 'src/types/v2'
interface StateProps {
sources: Source[]
}
type Props = StateProps
const SourcesList: SFC<Props> = props => {
const rows = props.sources.map(source => (
<SourcesListRow key={source.id} source={source} />
))
return (
<div className="sources-list col-xs-12">
<IndexList>
<IndexList.Header>
<IndexList.HeaderCell columnName="" width="10%" />
<IndexList.HeaderCell columnName="Name" width="20%" />
<IndexList.HeaderCell columnName="Type" width="10%" />
<IndexList.HeaderCell columnName="URL" width="30%" />
<IndexList.HeaderCell
columnName=""
width="30%"
alignment={Alignment.Right}
/>
</IndexList.Header>
<IndexList.Body emptyState={<div />} columnCount={4}>
{rows}
</IndexList.Body>
</IndexList>
</div>
)
}
const mstp = (state: AppState) => {
const sources = getSources(state)
return {sources}
}
export default connect<StateProps, {}, {}>(
mstp,
null
)(SourcesList)

View File

@ -0,0 +1,3 @@
.sources-list-row--connect-btn {
width: 80px;
}

View File

@ -0,0 +1,98 @@
// Libraries
import React, {SFC} from 'react'
import {connect} from 'react-redux'
// Components
import IndexList from 'src/shared/components/index_views/IndexList'
import {Button, ComponentColor, ComponentSize} from 'src/clockface'
import {Alignment} from 'src/clockface'
import DeleteSourceButton from 'src/sources/components/DeleteSourceButton'
// Actions
import {setActiveSource, deleteSource} from 'src/sources/actions'
// Styles
import 'src/sources/components/SourcesListRow.scss'
// Types
import {AppState} from 'src/types/v2'
import {Source} from 'src/types/v2'
interface StateProps {
activeSourceID: string
}
interface DispatchProps {
onSetActiveSource: typeof setActiveSource
onDeleteSource: (sourceID: string) => Promise<void>
}
interface OwnProps {
source: Source
}
type Props = StateProps & DispatchProps & OwnProps
const SourcesListRow: SFC<Props> = ({
source,
activeSourceID,
onSetActiveSource,
onDeleteSource,
}) => {
const canDelete = source.type !== 'self'
const isActiveSource = source.id === activeSourceID
const onButtonClick = () => onSetActiveSource(source.id)
const onDeleteClick = () => onDeleteSource(source.id)
let buttonText
let buttonColor
if (isActiveSource) {
buttonText = 'Connected'
buttonColor = ComponentColor.Success
} else {
buttonText = 'Connect'
buttonColor = ComponentColor.Default
}
return (
<IndexList.Row
key={source.id}
disabled={false}
customClass="sources-list-row"
>
<IndexList.Cell>
<Button
text={buttonText}
color={buttonColor}
size={ComponentSize.ExtraSmall}
customClass="sources-list-row--connect-btn"
onClick={onButtonClick}
/>
</IndexList.Cell>
<IndexList.Cell>{source.name}</IndexList.Cell>
<IndexList.Cell>{source.type}</IndexList.Cell>
<IndexList.Cell>{source.url ? source.url : 'N/A'}</IndexList.Cell>
<IndexList.Cell revealOnHover={true} alignment={Alignment.Right}>
{canDelete && <DeleteSourceButton onClick={onDeleteClick} />}
</IndexList.Cell>
</IndexList.Row>
)
}
const mstp = (state: AppState) => {
return {
activeSourceID: state.sources.activeSourceID,
}
}
const mdtp = dispatch => ({
onSetActiveSource: activeSourceID =>
dispatch(setActiveSource(activeSourceID)),
onDeleteSource: sourceID => dispatch(deleteSource(sourceID)),
})
export default connect<StateProps, DispatchProps, OwnProps>(
mstp,
mdtp
)(SourcesListRow)

View File

@ -0,0 +1,61 @@
// Libraries
import React, {PureComponent} from 'react'
// Components
import {Page} from 'src/pageLayout'
import {
Button,
IconFont,
ComponentColor,
OverlayTechnology,
} from 'src/clockface'
import SourcesList from 'src/sources/components/SourcesList'
import CreateSourceOverlay from 'src/sources/components/CreateSourceOverlay'
interface Props {}
interface State {
isAddingSource: boolean
}
class SourcesPage extends PureComponent<Props, State> {
public state: State = {isAddingSource: false}
public render() {
const {isAddingSource} = this.state
return (
<Page>
<Page.Header>
<Page.Header.Left>
<Page.Title title="Manage Sources" />
</Page.Header.Left>
<Page.Header.Right>
<Button
text="Create Source"
icon={IconFont.Plus}
color={ComponentColor.Primary}
onClick={this.handleShowOverlay}
/>
</Page.Header.Right>
</Page.Header>
<Page.Contents fullWidth={false} scrollable={true}>
<SourcesList />
<OverlayTechnology visible={isAddingSource}>
<CreateSourceOverlay onHide={this.handleHideOverlay} />
</OverlayTechnology>
</Page.Contents>
</Page>
)
}
private handleShowOverlay = () => {
this.setState({isAddingSource: true})
}
private handleHideOverlay = () => {
this.setState({isAddingSource: false})
}
}
export default SourcesPage

View File

@ -1,77 +0,0 @@
import React, {PureComponent} from 'react'
import {connect} from 'react-redux'
import {ErrorHandling} from 'src/shared/decorators/errors'
import * as sourcesActions from 'src/shared/actions/sources'
import {notify as notifyAction} from 'src/shared/actions/notifications'
import {Page} from 'src/pageLayout'
import InfluxTable from 'src/sources/components/InfluxTable'
import {sourceDeleted, sourceDeleteFailed} from 'src/shared/copy/notifications'
import {Source} from 'src/types/v2'
import {Notification, Service} from 'src/types'
interface Props {
source: Source
sources: Source[]
services: Service[]
notify: (n: Notification) => void
removeAndLoadSources: sourcesActions.RemoveAndLoadSources
}
const VERSION = process.env.npm_package_version
@ErrorHandling
class ManageSources extends PureComponent<Props> {
public render() {
const {sources, source} = this.props
return (
<Page>
<Page.Header fullWidth={false}>
<Page.Header.Left>
<Page.Title title="Configuration" />
</Page.Header.Left>
<Page.Header.Right />
</Page.Header>
<Page.Contents fullWidth={false} scrollable={true}>
<InfluxTable
source={source}
sources={sources}
onDeleteSource={this.handleDeleteSource}
/>
<p className="version-number">Chronograf Version: {VERSION}</p>
</Page.Contents>
</Page>
)
}
private handleDeleteSource = (source: Source) => {
const {notify} = this.props
try {
this.props.removeAndLoadSources(source)
notify(sourceDeleted(source.name))
} catch (e) {
notify(sourceDeleteFailed(source.name))
}
}
}
const mstp = ({source, sources, services}) => ({
source,
sources,
services,
})
const mdtp = {
notify: notifyAction,
removeAndLoadSources: sourcesActions.removeAndLoadSources,
}
export default connect(
mstp,
mdtp
)(ManageSources)

View File

@ -1,261 +0,0 @@
import React, {PureComponent, MouseEvent, ChangeEvent} from 'react'
import {withRouter, WithRouterProps} from 'react-router'
import {connect} from 'react-redux'
import _ from 'lodash'
import {createSource, updateSource} from 'src/sources/apis/v2'
import {
addSource as addSourceAction,
updateSource as updateSourceAction,
AddSource,
UpdateSource,
} from 'src/shared/actions/sources'
import {notify as notifyAction} from 'src/shared/actions/notifications'
import Notifications from 'src/shared/components/notifications/Notifications'
import SourceForm from 'src/sources/components/SourceForm'
import {Page, PageHeader, PageContents} from 'src/pageLayout'
import {DEFAULT_SOURCE} from 'src/shared/constants'
const INITIAL_PATH = '/sources/new'
import {
sourceUpdated,
sourceUpdateFailed,
sourceCreationFailed,
sourceCreationSucceeded,
} from 'src/shared/copy/notifications'
import {ErrorHandling} from 'src/shared/decorators/errors'
import {Source} from 'src/types/v2'
import * as NotificationsActions from 'src/types/actions/notifications'
interface Props extends WithRouterProps {
notify: NotificationsActions.PublishNotificationActionCreator
addSource: AddSource
updateSource: UpdateSource
sourcesLink: string
sources: Source[]
}
interface State {
isCreated: boolean
source: Partial<Source>
editMode: boolean
isInitialSource: boolean
}
@ErrorHandling
class SourcePage extends PureComponent<Props, State> {
constructor(props) {
super(props)
this.state = {
isCreated: false,
source: DEFAULT_SOURCE,
editMode: props.params.id !== undefined,
isInitialSource: props.router.location.pathname === INITIAL_PATH,
}
}
public async componentDidMount() {
this.setState({
source: this.source,
})
}
public render() {
const {source, editMode, isInitialSource} = this.state
return (
<Page>
<Notifications />
<PageHeader fullWidth={false}>
<PageHeader.Left>
<h1 className="page--title">{this.pageTitle}</h1>
</PageHeader.Left>
<PageHeader.Right />
</PageHeader>
<PageContents fullWidth={false} scrollable={true}>
<div className="col-md-8 col-md-offset-2">
<div className="panel">
<SourceForm
source={source}
editMode={editMode}
onInputChange={this.handleInputChange}
onSubmit={this.handleSubmit}
onBlurSourceURL={this.handleBlurSourceURL}
isInitialSource={isInitialSource}
/>
</div>
</div>
</PageContents>
</Page>
)
}
private get source(): Partial<Source> {
const {sources, params} = this.props
const source = sources.find(s => s.id === params.id) || {}
return {...DEFAULT_SOURCE, ...source}
}
private handleSubmit = (e: MouseEvent<HTMLFormElement>): void => {
e.preventDefault()
const {isCreated, editMode} = this.state
const isNewSource = !editMode
if (!isCreated && isNewSource) {
return this.setState(this.normalizeSource, this.createSource)
}
this.setState(this.normalizeSource, this.updateSource)
}
private normalizeSource({source}) {
const url = source.url.trim()
if (source.url.startsWith('http')) {
return {source: {...source, url}}
}
return {source: {...source, url: `http://${url}`}}
}
private createSourceOnBlur = async () => {
const {source} = this.state
const {sourcesLink} = this.props
// if there is a type on source it has already been created
if (source.type) {
return
}
try {
const sourceFromServer = await createSource(sourcesLink, source)
this.props.addSource(sourceFromServer)
this.setState({
source: {...DEFAULT_SOURCE, ...sourceFromServer},
isCreated: true,
})
} catch (err) {
// dont want to flash this until they submit
const error = this.parseError(err)
console.error('Error creating InfluxDB connection: ', error)
}
}
private createSource = async () => {
const {source} = this.state
const {notify, sourcesLink} = this.props
try {
const sourceFromServer = await createSource(sourcesLink, source)
this.props.addSource(sourceFromServer)
this.redirect(sourceFromServer)
notify(sourceCreationSucceeded(source.name))
} catch (err) {
// dont want to flash this until they submit
notify(sourceCreationFailed(source.name, this.parseError(err)))
}
}
private updateSource = async () => {
const {source} = this.state
const {notify} = this.props
try {
const sourceFromServer = await updateSource(source)
this.props.updateSource(sourceFromServer)
this.redirect(sourceFromServer)
notify(sourceUpdated(source.name))
} catch (error) {
notify(sourceUpdateFailed(source.name, this.parseError(error)))
}
}
private redirect = source => {
const {isInitialSource} = this.state
const {router, location} = this.props
const sourceID = location.query.sourceID
if (isInitialSource) {
return this.redirectToApp(source)
}
router.push(`/manage-sources?sourceID=${sourceID}`)
}
private parseError = (error): string => {
return _.get(error, ['data', 'message'], error)
}
private redirectToApp = source => {
const {location, router} = this.props
const {redirectPath} = location.query
if (!redirectPath) {
return router.push(`/sources/${source.id}/hosts`)
}
const fixedPath = redirectPath.replace(
/\/sources\/[^/]*/,
`/sources/${source.id}`
)
return router.push(fixedPath)
}
private handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
let val = e.target.value
const name = e.target.name
if (e.target.type === 'checkbox') {
val = e.target.checked as any
}
this.setState(prevState => {
const source = {
...prevState.source,
[name]: val,
}
return {...prevState, source}
})
}
private handleBlurSourceURL = () => {
const {source, editMode} = this.state
if (editMode) {
this.setState(this.normalizeSource)
return
}
if (!source.url) {
return
}
this.setState(this.normalizeSource, this.createSourceOnBlur)
}
private get pageTitle(): string {
const {editMode} = this.state
if (editMode) {
return 'Configure InfluxDB Connection'
}
return 'Add a New InfluxDB Connection'
}
}
const mdtp = {
notify: notifyAction,
addSource: addSourceAction,
updateSource: updateSourceAction,
}
const mstp = ({links, sources}) => ({
sourcesLink: links.sources,
sources,
})
export default connect(
mstp,
mdtp
)(withRouter(SourcePage))

View File

@ -1,3 +0,0 @@
import SourcePage from './containers/SourcePage'
import ManageSources from './containers/ManageSources'
export {SourcePage, ManageSources}

View File

@ -0,0 +1,55 @@
import {Source} from 'src/types/v2'
import {Action} from 'src/sources/actions'
export interface SourcesState {
activeSourceID: string | null
sources: {
[sourceID: string]: Source
}
}
const initialState: SourcesState = {
activeSourceID: null,
sources: {},
}
const sourcesReducer = (
state: SourcesState = initialState,
action: Action
): SourcesState => {
switch (action.type) {
case 'SET_ACTIVE_SOURCE': {
const {activeSourceID} = action.payload
return {...state, activeSourceID}
}
case 'SET_SOURCES': {
const sources = {...state.sources}
for (const source of action.payload.sources) {
sources[source.id] = source
}
return {...state, sources}
}
case 'SET_SOURCE': {
const {source} = action.payload
return {...state, sources: {...state.sources, [source.id]: source}}
}
case 'REMOVE_SOURCE': {
const sources = {...state.sources}
delete sources[action.payload.sourceID]
return {...state, sources}
}
}
return state
}
export default sourcesReducer

View File

@ -1,74 +0,0 @@
import reducer, {initialState} from 'src/sources/reducers/sources'
import {updateSource, addSource, loadSources} from 'src/shared/actions/sources'
import {source} from 'src/sources/resources'
describe('sources reducer', () => {
it('can LOAD_SOURCES', () => {
const expected = [{...source, id: '1'}]
const actual = reducer(initialState, loadSources(expected))
expect(actual).toEqual(expected)
})
describe('ADD_SOURCES', () => {
it('can ADD_SOURCES', () => {
let state = []
state = reducer(
state,
addSource({
...source,
id: '1',
default: true,
})
)
state = reducer(
state,
addSource({
...source,
id: '2',
default: true,
})
)
expect(state.filter(s => s.default).length).toBe(1)
})
it('can correctly show default sources when updating a source', () => {
let state = []
state = reducer(
initialState,
addSource({
...source,
id: '1',
default: true,
})
)
state = reducer(
state,
addSource({
...source,
id: '2',
default: true,
})
)
state = reducer(
state,
updateSource({
...source,
id: '1',
default: true,
})
)
expect(state.find(({id}) => id === '1').default).toBe(true)
expect(state.find(({id}) => id === '2').default).toBe(false)
})
})
})

View File

@ -1,40 +0,0 @@
import {Source} from 'src/types/v2'
import {Action} from 'src/shared/actions/sources'
export const initialState: Source[] = []
const sourcesReducer = (state = initialState, action: Action): Source[] => {
switch (action.type) {
case 'LOAD_SOURCES': {
return action.payload.sources
}
case 'SOURCE_UPDATED': {
const {source} = action.payload
const updatedIndex = state.findIndex(s => s.id === source.id)
const updatedSources = source.default
? state.map(s => {
s.default = false
return s
})
: [...state]
updatedSources[updatedIndex] = source
return updatedSources
}
case 'SOURCE_ADDED': {
const {source} = action.payload
const updatedSources = source.default
? state.map(s => {
s.default = false
return s
})
: state
return [...updatedSources, source]
}
}
return state
}
export default sourcesReducer

View File

@ -1,21 +0,0 @@
import {Source} from 'src/types/v2'
import {SourceLinks, SourceAuthenticationMethod} from 'src/types/v2/sources'
export const sourceLinks: SourceLinks = {
query: '/v2/sources/16/query',
self: '/v2/sources/16',
health: '/v2/sources/16/health',
buckets: '/v2/sources/16/buckets',
}
export const source: Source = {
id: '16',
name: 'ssl',
type: 'influx',
username: 'admin',
url: 'https://localhost:9086',
insecureSkipVerify: true,
default: false,
telegraf: 'telegraf',
links: sourceLinks,
authentication: SourceAuthenticationMethod.Basic,
}

View File

@ -0,0 +1,20 @@
import {AppState, Source} from 'src/types/v2'
export const getActiveSource = (state: AppState): Source | null => {
const {activeSourceID} = state.sources
if (!activeSourceID) {
return null
}
const source = state.sources.sources[activeSourceID]
if (!source) {
return null
}
return source
}
export const getSources = (state: AppState): Source[] =>
Object.values(state.sources.sources)

View File

@ -8,7 +8,7 @@ import {resizeLayout} from 'src/shared/middleware/resizeLayout'
import {queryStringConfig} from 'src/shared/middleware/queryStringConfig'
import sharedReducers from 'src/shared/reducers'
import persistStateEnhancer from './persistStateEnhancer'
import sourcesReducer from 'src/sources/reducers/sources'
import sourcesReducer from 'src/sources/reducers'
// v2 reducers
import meReducer from 'src/shared/reducers/v2/me'
@ -19,7 +19,6 @@ import viewsReducer from 'src/dashboards/reducers/v2/views'
import logsReducer from 'src/logs/reducers'
import timeMachinesReducer from 'src/shared/reducers/v2/timeMachines'
import orgsReducer from 'src/organizations/reducers/orgs'
import sourceReducer from 'src/shared/reducers/v2/source'
// Types
import {LocalStorage} from 'src/types/localStorage'
@ -42,7 +41,6 @@ const rootReducer = combineReducers<ReducerState>({
tasks: tasksReducer,
orgs: orgsReducer,
me: meReducer,
source: sourceReducer,
})
const composeEnhancers =

View File

@ -28,8 +28,8 @@ import {AppState as AppPresentationState} from 'src/shared/reducers/app'
import {State as TaskState} from 'src/tasks/reducers/v2'
import {RouterState} from 'react-router-redux'
import {MeState} from 'src/shared/reducers/v2/me'
import {SourceState} from 'src/shared/reducers/v2/source'
import {OverlayState} from 'src/types/v2/overlay'
import {SourcesState} from 'src/sources/reducers'
export interface AppState {
VERSION: string
@ -38,7 +38,7 @@ export interface AppState {
logs: LogsState
ranges: RangeState
views: ViewsState
sources: Source[]
sources: SourcesState
dashboards: Dashboard[]
notifications: Notification[]
timeMachines: TimeMachinesState
@ -47,9 +47,10 @@ export interface AppState {
timeRange: TimeRange
orgs: Organization[]
me: MeState
source: SourceState
}
export type GetState = () => AppState
export {
User,
UserToken,