Merge pull request #1608 from influxdata/feature/status_page-1556

Add Status Page (incl. Alert Events, Recent Alerts, News Feed, and Getting Started guide)
pull/1627/head
Jared Scheib 2017-06-16 18:00:02 -07:00 committed by GitHub
commit 7f54f987d3
56 changed files with 1467 additions and 379 deletions

View File

@ -7,6 +7,7 @@
### Features
1. [#1512](https://github.com/influxdata/chronograf/pull/1512): Synchronize vertical crosshair at same time across all graphs in a dashboard
1. [#1608](https://github.com/influxdata/chronograf/pull/1608): Add a Status Page with Recent Alerts bar graph, Recent Alerts table, News Feed, and Getting Started widgets
### UI Improvements
1. [#1512](https://github.com/influxdata/chronograf/pull/1512): When dashboard time range is changed, reset graphs that are zoomed in

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"he": "^1.1.1"
}
}

View File

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

View File

@ -29,20 +29,27 @@ func (r *AuthRoutes) Lookup(provider string) (AuthRoute, bool) {
}
type getRoutesResponse struct {
Layouts string `json:"layouts"` // Location of the layouts endpoint
Mappings string `json:"mappings"` // Location of the application mappings endpoint
Sources string `json:"sources"` // Location of the sources endpoint
Me string `json:"me"` // Location of the me endpoint
Dashboards string `json:"dashboards"` // Location of the dashboards endpoint
Auth []AuthRoute `json:"auth"` // Location of all auth routes.
Logout *string `json:"logout,omitempty"` // Location of the logout route for all auth routes
Layouts string `json:"layouts"` // Location of the layouts endpoint
Mappings string `json:"mappings"` // Location of the application mappings endpoint
Sources string `json:"sources"` // Location of the sources endpoint
Me string `json:"me"` // Location of the me endpoint
Dashboards string `json:"dashboards"` // Location of the dashboards endpoint
Auth []AuthRoute `json:"auth"` // Location of all auth routes.
Logout *string `json:"logout,omitempty"` // Location of the logout route for all auth routes
ExternalLinks getExternalLinksResponse `json:"external"` // All external links for the client to use
}
// AllRoutes is a handler that returns all links to resources in Chronograf server.
type getExternalLinksResponse struct {
StatusFeed *string `json:"statusFeed,omitempty"` // Location of the a JSON Feed for client's Status page News Feed
}
// AllRoutes is a handler that returns all links to resources in Chronograf server, as well as
// external links for the client to know about, such as for JSON feeds or custom side nav buttons.
// Optionally, routes for authentication can be returned.
type AllRoutes struct {
AuthRoutes []AuthRoute // Location of all auth routes. If no auth, this can be empty.
LogoutLink string // Location of the logout route for all auth routes. If no auth, this can be empty.
StatusFeed string // External link to the JSON Feed for the News Feed on the client's Status Page
Logger chronograf.Logger
}
@ -55,6 +62,9 @@ func (a *AllRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Mappings: "/chronograf/v1/mappings",
Dashboards: "/chronograf/v1/dashboards",
Auth: make([]AuthRoute, len(a.AuthRoutes)), // We want to return at least an empty array, rather than null
ExternalLinks: getExternalLinksResponse{
StatusFeed: &a.StatusFeed,
},
}
// The JSON response will have no field present for the LogoutLink if there is no logout link.

View File

@ -29,7 +29,7 @@ func TestAllRoutes(t *testing.T) {
if err := json.Unmarshal(body, &routes); err != nil {
t.Error("TestAllRoutes not able to unmarshal JSON response")
}
want := `{"layouts":"/chronograf/v1/layouts","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[]}
want := `{"layouts":"/chronograf/v1/layouts","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[],"external":{"statusFeed":""}}
`
if want != string(body) {
t.Errorf("TestAllRoutes\nwanted\n*%s*\ngot\n*%s*", want, string(body))
@ -67,9 +67,38 @@ func TestAllRoutesWithAuth(t *testing.T) {
if err := json.Unmarshal(body, &routes); err != nil {
t.Error("TestAllRoutesWithAuth not able to unmarshal JSON response")
}
want := `{"layouts":"/chronograf/v1/layouts","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[{"name":"github","label":"GitHub","login":"/oauth/github/login","logout":"/oauth/github/logout","callback":"/oauth/github/callback"}],"logout":"/oauth/logout"}
want := `{"layouts":"/chronograf/v1/layouts","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[{"name":"github","label":"GitHub","login":"/oauth/github/login","logout":"/oauth/github/logout","callback":"/oauth/github/callback"}],"logout":"/oauth/logout","external":{"statusFeed":""}}
`
if want != string(body) {
t.Errorf("TestAllRoutesWithAuth\nwanted\n*%s*\ngot\n*%s*", want, string(body))
}
}
func TestAllRoutesWithExternalLinks(t *testing.T) {
statusFeedURL := "http://pineapple.life/feed.json"
logger := log.New(log.DebugLevel)
handler := &AllRoutes{
StatusFeed: statusFeedURL,
Logger: logger,
}
req := httptest.NewRequest("GET", "http://docbrowns-inventions.com", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
resp := w.Result()
body, err := ioutil.ReadAll(resp.Body)
defer resp.Body.Close()
if err != nil {
t.Error("TestAllRoutesWithExternalLinks not able to retrieve body")
}
var routes getRoutesResponse
if err := json.Unmarshal(body, &routes); err != nil {
t.Error("TestAllRoutesWithExternalLinks not able to unmarshal JSON response")
}
want := `{"layouts":"/chronograf/v1/layouts","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[],"external":{"statusFeed":"http://pineapple.life/feed.json"}}
`
if want != string(body) {
t.Errorf("TestAllRoutesWithExternalLinks\nwanted\n*%s*\ngot\n*%s*", want, string(body))
}
}

View File

@ -76,6 +76,8 @@ type Server struct {
GenericTokenURL string `long:"generic-token-url" description:"OAuth 2.0 provider's token endpoint URL" env:"GENERIC_TOKEN_URL"`
GenericAPIURL string `long:"generic-api-url" description:"URL that returns OpenID UserInfo compatible information." env:"GENERIC_API_URL"`
StatusFeedURL string `long:"status-feed-url" description:"URL of a JSON Feed to display as a News Feed on the client Status page." default:"https://www.influxdata.com/feed/json" env:"STATUS_FEED_URL"`
ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"`
LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"error" default:"info" description:"Set the logging level" env:"LOG_LEVEL"`
Basepath string `short:"p" long:"basepath" description:"A URL path prefix under which all chronograf routes will be mounted" env:"BASE_PATH"`
@ -258,6 +260,7 @@ func (s *Server) Serve(ctx context.Context) error {
ProviderFuncs: providerFuncs,
Basepath: basepath,
PrefixRoutes: s.PrefixRoutes,
StatusFeedURL: s.StatusFeedURL,
}, service)
// Add chronograf's version header to all requests

View File

@ -4106,6 +4106,17 @@
"description": "location of the dashboards endpoint",
"type": "string",
"format": "url"
},
"external": {
"description": "external links provided to client, ex. status feed URL",
"type": "object",
"properties": {
"statusFeed": {
"description": "link to a JSON Feed for the News Feed on client's Status Page",
"type": "string",
"format": "url"
}
}
}
},
"example": {
@ -4113,7 +4124,10 @@
"mappings": "/chronograf/v1/mappings",
"sources": "/chronograf/v1/sources",
"me": "/chronograf/v1/me",
"dashboards": "/chronograf/v1/dashboards"
"dashboards": "/chronograf/v1/dashboards",
"external": {
"statusFeed": "http://news.influxdata.com/feed.json"
}
}
},
"Link": {

View File

@ -0,0 +1,35 @@
import _ from 'lodash'
import linksReducer from 'shared/reducers/links'
import {linksReceived} from 'shared/actions/links'
import {noop} from 'shared/actions/app'
const links = {
layouts: '/chronograf/v1/layouts',
mappings: '/chronograf/v1/mappings',
sources: '/chronograf/v1/sources',
me: '/chronograf/v1/me',
dashboards: '/chronograf/v1/dashboards',
auth: [
{
name: 'github',
label: 'Github',
login: '/oauth/github/login',
logout: '/oauth/github/logout',
callback: '/oauth/github/callback',
},
],
logout: '/oauth/logout',
external: {statusFeed: 'http://pineapple.life'},
}
describe('Shared.Reducers.linksReducer', () => {
it('can handle LINKS_RECEIVED', () => {
const initial = linksReducer(undefined, noop())
const actual = linksReducer(initial, linksReceived(links))
const expected = links
expect(_.isEqual(actual, expected)).to.equal(true)
})
})

View File

@ -9,6 +9,8 @@ import {showDatabases} from 'shared/apis/metaQuery'
import {loadSources as loadSourcesAction} from 'shared/actions/sources'
import {errorThrown as errorThrownAction} from 'shared/actions/errors'
import {DEFAULT_HOME_PAGE} from 'shared/constants'
// Acts as a 'router middleware'. The main `App` component is responsible for
// getting the list of data nodes, but not every page requires them to function.
// Routes that do require data nodes can be nested under this component.
@ -88,7 +90,7 @@ const CheckSources = React.createClass({
if (!isFetching && !source) {
const rest = location.pathname.match(/\/sources\/\d+?\/(.+)/)
const restString = rest === null ? 'hosts' : rest[1]
const restString = rest === null ? DEFAULT_HOME_PAGE : rest[1]
if (defaultSource) {
return router.push(`/sources/${defaultSource.id}/${restString}`)

View File

@ -1,9 +1,10 @@
import {proxy} from 'utils/queryUrlGenerator'
export function getAlerts(source, timeRange) {
return proxy({
export const getAlerts = (source, timeRange, limit) =>
proxy({
source,
query: `SELECT host, value, level, alertName FROM alerts WHERE time >= '${timeRange.lower}' AND time <= '${timeRange.upper}' ORDER BY time desc`,
query: `SELECT host, value, level, alertName FROM alerts WHERE time >= '${timeRange.lower}' AND time <= '${timeRange.upper}' ORDER BY time desc ${limit
? `LIMIT ${limit}`
: ''}`,
db: 'chronograf',
})
}

View File

@ -1,36 +1,27 @@
import React, {PropTypes} from 'react'
import React, {Component, PropTypes} from 'react'
import _ from 'lodash'
import {Link} from 'react-router'
const AlertsTable = React.createClass({
propTypes: {
alerts: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
time: PropTypes.string,
value: PropTypes.string,
host: PropTypes.string,
level: PropTypes.string,
})
),
source: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
},
class AlertsTable extends Component {
constructor(props) {
super(props)
getInitialState() {
return {
this.state = {
searchTerm: '',
filteredAlerts: this.props.alerts,
sortDirection: null,
sortKey: null,
}
},
this.filterAlerts = ::this.filterAlerts
this.changeSort = ::this.changeSort
this.sortableClasses = ::this.sortableClasses
this.sort = ::this.sort
}
componentWillReceiveProps(newProps) {
this.filterAlerts(this.state.searchTerm, newProps.alerts)
},
}
filterAlerts(searchTerm, newAlerts) {
const alerts = newAlerts || this.props.alerts
@ -47,7 +38,7 @@ const AlertsTable = React.createClass({
)
})
this.setState({searchTerm, filteredAlerts})
},
}
changeSort(key) {
// if we're using the key, reverse order; otherwise, set it with ascending
@ -59,7 +50,17 @@ const AlertsTable = React.createClass({
} else {
this.setState({sortKey: key, sortDirection: 'asc'})
}
},
}
sortableClasses(key) {
if (this.state.sortKey === key) {
if (this.state.sortDirection === 'asc') {
return 'sortable-header sorting-ascending'
}
return 'sortable-header sorting-descending'
}
return 'sortable-header'
}
sort(alerts, key, direction) {
switch (direction) {
@ -70,125 +71,167 @@ const AlertsTable = React.createClass({
default:
return alerts
}
},
}
render() {
const {id} = this.props.source
renderTable() {
const {source: {id}} = this.props
const alerts = this.sort(
this.state.filteredAlerts,
this.state.sortKey,
this.state.sortDirection
)
return (
<div className="panel panel-minimal">
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<h2 className="panel-title">{this.props.alerts.length} Alerts</h2>
{this.props.alerts.length
? <SearchBar onSearch={this.filterAlerts} />
return this.props.alerts.length
? <table className="table v-center table-highlight">
<thead>
<tr>
<th
onClick={() => this.changeSort('name')}
className={this.sortableClasses('name')}
>
Name
</th>
<th
onClick={() => this.changeSort('level')}
className={this.sortableClasses('level')}
>
Level
</th>
<th
onClick={() => this.changeSort('time')}
className={this.sortableClasses('time')}
>
Time
</th>
<th
onClick={() => this.changeSort('host')}
className={this.sortableClasses('host')}
>
Host
</th>
<th
onClick={() => this.changeSort('value')}
className={this.sortableClasses('value')}
>
Value
</th>
</tr>
</thead>
<tbody>
{alerts.map(({name, level, time, host, value}) => {
return (
<tr key={`${name}-${level}-${time}-${host}-${value}`}>
<td className="monotype">{name}</td>
<td className={`monotype alert-level-${level.toLowerCase()}`}>
{level}
</td>
<td className="monotype">
{new Date(Number(time)).toISOString()}
</td>
<td className="monotype">
<Link to={`/sources/${id}/hosts/${host}`}>
{host}
</Link>
</td>
<td className="monotype">{value}</td>
</tr>
)
})}
</tbody>
</table>
: this.renderTableEmpty()
}
renderTableEmpty() {
const {source: {id}, shouldNotBeFilterable} = this.props
return shouldNotBeFilterable
? <div className="graph-empty">
<p>
Learn how to configure your first <strong>Rule</strong> in<br />
the <em>Getting Started</em> guide
</p>
</div>
: <div className="generic-empty-state">
<h4 className="no-user-select">
There are no Alerts to display
</h4>
<br />
<h6 className="no-user-select">
Try changing the Time Range or
<Link
style={{marginLeft: '10px'}}
to={`/sources/${id}/alert-rules/new`}
className="btn btn-primary btn-sm"
>
Create an Alert Rule
</Link>
</h6>
</div>
}
render() {
const {
shouldNotBeFilterable,
limit,
onGetMoreAlerts,
isAlertsMaxedOut,
alertsCount,
} = this.props
return shouldNotBeFilterable
? <div className="alerts-widget">
{this.renderTable()}
{limit && alertsCount
? <button
className="btn btn-sm btn-default btn-block"
onClick={onGetMoreAlerts}
disabled={isAlertsMaxedOut}
style={{marginBottom: '20px'}}
>
{isAlertsMaxedOut
? `All ${alertsCount} Alerts displayed`
: 'Load next 30 Alerts'}
</button>
: null}
</div>
<div className="panel-body">
{this.props.alerts.length
? <table className="table v-center table-highlight">
<thead>
<tr>
<th
onClick={() => this.changeSort('name')}
className="sortable-header"
>
Name
</th>
<th
onClick={() => this.changeSort('level')}
className="sortable-header"
>
Level
</th>
<th
onClick={() => this.changeSort('time')}
className="sortable-header"
>
Time
</th>
<th
onClick={() => this.changeSort('host')}
className="sortable-header"
>
Host
</th>
<th
onClick={() => this.changeSort('value')}
className="sortable-header"
>
Value
</th>
</tr>
</thead>
<tbody>
{alerts.map(({name, level, time, host, value}) => {
return (
<tr key={`${name}-${level}-${time}-${host}-${value}`}>
<td className="monotype">{name}</td>
<td
className={`monotype alert-level-${level.toLowerCase()}`}
>
{level}
</td>
<td className="monotype">
{new Date(Number(time)).toISOString()}
</td>
<td className="monotype">
<Link to={`/sources/${id}/hosts/${host}`}>
{host}
</Link>
</td>
<td className="monotype">{value}</td>
</tr>
)
})}
</tbody>
</table>
: <div className="generic-empty-state">
<h5 className="no-user-select">
Alerts appear here when you have Rules
</h5>
<br />
<Link
to={`/sources/${id}/alert-rules/new`}
className="btn btn-primary"
>
Create a Rule
</Link>
</div>}
: <div className="panel panel-minimal">
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<h2 className="panel-title">{this.props.alerts.length} Alerts</h2>
{this.props.alerts.length
? <SearchBar onSearch={this.filterAlerts} />
: null}
</div>
<div className="panel-body">
{this.renderTable()}
</div>
</div>
</div>
)
},
})
}
}
const SearchBar = React.createClass({
propTypes: {
onSearch: PropTypes.func.isRequired,
},
class SearchBar extends Component {
constructor(props) {
super(props)
getInitialState() {
return {
this.state = {
searchTerm: '',
}
},
this.handleSearch = ::this.handleSearch
this.handleChange = ::this.handleChange
}
componentWillMount() {
const waitPeriod = 300
this.handleSearch = _.debounce(this.handleSearch, waitPeriod)
},
}
handleSearch() {
this.props.onSearch(this.state.searchTerm)
},
}
handleChange(e) {
this.setState({searchTerm: e.target.value}, this.handleSearch)
},
}
render() {
return (
@ -205,7 +248,34 @@ const SearchBar = React.createClass({
</div>
</div>
)
},
})
}
}
const {arrayOf, bool, func, number, shape, string} = PropTypes
AlertsTable.propTypes = {
alerts: arrayOf(
shape({
name: string,
time: string,
value: string,
host: string,
level: string,
})
),
source: shape({
id: string.isRequired,
name: string.isRequired,
}).isRequired,
shouldNotBeFilterable: bool,
limit: number,
onGetMoreAlerts: func,
isAlertsMaxedOut: bool,
alertsCount: number,
}
SearchBar.propTypes = {
onSearch: func.isRequired,
}
export default AlertsTable

View File

@ -6,15 +6,24 @@ import NoKapacitorError from 'shared/components/NoKapacitorError'
import CustomTimeRangeDropdown from 'shared/components/CustomTimeRangeDropdown'
import FancyScrollbar from 'shared/components/FancyScrollbar'
import {getAlerts} from '../apis'
import {getAlerts} from 'src/alerts/apis'
import AJAX from 'utils/ajax'
import _ from 'lodash'
import moment from 'moment'
import timeRanges from 'hson!shared/data/timeRanges.hson'
class AlertsApp extends Component {
constructor(props) {
super(props)
const lowerInSec = props.timeRange
? timeRanges.find(tr => tr.lower === props.timeRange.lower).seconds
: undefined
const oneDayInSec = 86400
this.state = {
loading: true,
hasKapacitor: false,
@ -22,12 +31,16 @@ class AlertsApp extends Component {
isTimeOpen: false,
timeRange: {
upper: moment().format(),
lower: moment().subtract(1, 'd').format(),
lower: moment().subtract(lowerInSec || oneDayInSec, 'seconds').format(),
},
limit: props.limit || 0, // only used if AlertsApp receives a limit prop
limitMultiplier: 1, // only used if AlertsApp receives a limit prop
isAlertsMaxedOut: false, // only used if AlertsApp receives a limit prop
}
this.fetchAlerts = ::this.fetchAlerts
this.renderSubComponents = ::this.renderSubComponents
this.handleGetMoreAlerts = ::this.handleGetMoreAlerts
this.handleToggleTime = ::this.handleToggleTime
this.handleCloseTime = ::this.handleCloseTime
this.handleApplyTime = ::this.handleApplyTime
@ -59,7 +72,8 @@ class AlertsApp extends Component {
fetchAlerts() {
getAlerts(
this.props.source.links.proxy,
this.state.timeRange
this.state.timeRange,
this.state.limit * this.state.limitMultiplier
).then(resp => {
const results = []
@ -90,23 +104,39 @@ class AlertsApp extends Component {
name: `${s[nameIndex]}`,
})
})
this.setState({loading: false, alerts: results})
// TODO: factor these setStates out to make a pure function and implement true limit & offset
this.setState({
loading: false,
alerts: results,
// this.state.alerts.length === results.length ||
isAlertsMaxedOut:
results.length !== this.props.limit * this.state.limitMultiplier,
})
})
}
handleGetMoreAlerts() {
this.setState({limitMultiplier: this.state.limitMultiplier + 1}, () => {
this.fetchAlerts(this.state.limitMultiplier)
})
}
renderSubComponents() {
const {source} = this.props
return (
<div className="container-fluid">
<div className="row">
<div className="col-md-12">
{this.state.hasKapacitor
? <AlertsTable source={source} alerts={this.state.alerts} />
: <NoKapacitorError source={source} />}
</div>
</div>
</div>
)
const {source, isWidget, limit} = this.props
const {isAlertsMaxedOut, alerts} = this.state
return this.state.hasKapacitor
? <AlertsTable
source={source}
alerts={this.state.alerts}
shouldNotBeFilterable={isWidget}
limit={limit}
onGetMoreAlerts={this.handleGetMoreAlerts}
isAlertsMaxedOut={isAlertsMaxedOut}
alertsCount={alerts.length}
/>
: <NoKapacitorError source={source} />
}
handleToggleTime() {
@ -122,43 +152,51 @@ class AlertsApp extends Component {
}
render() {
const {source} = this.props
const {isWidget, source} = this.props
const {loading, timeRange} = this.state
if (loading || !source) {
return <div className="page-spinner" />
}
return (
<div className="page">
<div className="page-header">
<div className="page-header__container">
<div className="page-header__left">
<h1 className="page-header__title">
Alert History
</h1>
</div>
<div className="page-header__right">
<SourceIndicator sourceName={source.name} />
<CustomTimeRangeDropdown
isVisible={this.state.isTimeOpen}
onToggle={this.handleToggleTime}
onClose={this.handleCloseTime}
onApplyTimeRange={this.handleApplyTime}
timeRange={timeRange}
/>
</div>
</div>
</div>
<FancyScrollbar className="page-contents">
return isWidget
? <FancyScrollbar autoHide={false}>
{this.renderSubComponents()}
</FancyScrollbar>
</div>
)
: <div className="page">
<div className="page-header">
<div className="page-header__container">
<div className="page-header__left">
<h1 className="page-header__title">
Alert History
</h1>
</div>
<div className="page-header__right">
<SourceIndicator sourceName={source.name} />
<CustomTimeRangeDropdown
isVisible={this.state.isTimeOpen}
onToggle={this.handleToggleTime}
onClose={this.handleCloseTime}
onApplyTimeRange={this.handleApplyTime}
timeRange={timeRange}
/>
</div>
</div>
</div>
<FancyScrollbar className="page-contents">
<div className="container-fluid">
<div className="row">
<div className="col-md-12">
{this.renderSubComponents()}
</div>
</div>
</div>
</FancyScrollbar>
</div>
}
}
const {func, shape, string} = PropTypes
const {bool, number, oneOfType, shape, string} = PropTypes
AlertsApp.propTypes = {
source: shape({
@ -169,7 +207,12 @@ AlertsApp.propTypes = {
proxy: string.isRequired,
}).isRequired,
}),
addFlashMessage: func,
timeRange: shape({
lower: string.isRequired,
upper: oneOfType([shape(), string]),
}),
isWidget: bool,
limit: number,
}
export default AlertsApp

View File

@ -64,7 +64,7 @@ const Dashboard = ({
cells={cells}
timeRange={timeRange}
autoRefresh={autoRefresh}
source={source.links.proxy}
source={source}
onPositionChange={onPositionChange}
onEditCell={onEditCell}
onRenameCell={onRenameCell}

View File

@ -193,12 +193,9 @@ class DashboardPage extends Component {
handleEditTemplateVariables(templates, onSaveTemplatesSuccess) {
return async () => {
const {params: {dashboardID}, dashboards} = this.props
const currentDashboard = dashboards.find(({id}) => id === +dashboardID)
try {
await this.props.dashboardActions.putDashboard({
...currentDashboard,
...this.getActiveDashboard(),
templates,
})
onSaveTemplatesSuccess()
@ -242,10 +239,10 @@ class DashboardPage extends Component {
inPresentationMode,
handleChooseAutoRefresh,
handleClickPresentationButton,
params: {sourceID, dashboardID},
params: {sourceID},
} = this.props
const dashboard = dashboards.find(d => d.id === +dashboardID)
const dashboard = this.getActiveDashboard()
const dashboardTime = {
id: 'dashtime',
tempVar: ':dashboardTime:',

View File

@ -1,7 +1,7 @@
import _ from 'lodash'
import timeRanges from 'hson!shared/data/timeRanges.hson'
const {lower, upper} = timeRanges[2]
const {lower, upper} = timeRanges.find(tr => tr.lower === 'now() - 1h')
const initialState = {
dashboards: [],

View File

@ -1,6 +1,6 @@
import React, {PropTypes} from 'react'
import groupByTimeOptions from 'hson!../data/groupByTimes.hson'
import groupByTimeOptions from 'hson!src/data_explorer/data/groupByTimes.hson'
import Dropdown from 'shared/components/Dropdown'

View File

@ -1,6 +1,6 @@
import timeRanges from 'hson!shared/data/timeRanges.hson'
const {lower, upper} = timeRanges[2]
const {lower, upper} = timeRanges.find(tr => tr.lower === 'now() - 1h')
const initialState = {
upper,

View File

@ -47,12 +47,10 @@ export const HostPage = React.createClass({
},
getInitialState() {
const timeRange = timeRanges[2]
return {
layouts: [],
hosts: [],
timeRange,
timeRange: timeRanges.find(tr => tr.lower === 'now() - 1h'),
}
},
@ -155,7 +153,7 @@ export const HostPage = React.createClass({
timeRange={timeRange}
cells={layoutCells}
autoRefresh={autoRefresh}
source={source.links.proxy}
source={source}
host={this.props.params.hostID}
shouldNotBeEditable={true}
/>

View File

@ -5,24 +5,26 @@ import {Router, Route, useRouterHistory} from 'react-router'
import {createHistory} from 'history'
import {syncHistoryWithStore} from 'react-router-redux'
import configureStore from 'src/store/configureStore'
import {loadLocalStorage} from 'src/localStorage'
import App from 'src/App'
import AlertsApp from 'src/alerts'
import CheckSources from 'src/CheckSources'
import {HostsPage, HostPage} from 'src/hosts'
import {Login, UserIsAuthenticated, UserIsNotAuthenticated} from 'src/auth'
import CheckSources from 'src/CheckSources'
import {StatusPage} from 'src/status'
import {HostsPage, HostPage} from 'src/hosts'
import DataExplorer from 'src/data_explorer'
import {DashboardsPage, DashboardPage} from 'src/dashboards'
import AlertsApp from 'src/alerts'
import {
KapacitorPage,
KapacitorRulePage,
KapacitorRulesPage,
KapacitorTasksPage,
} from 'src/kapacitor'
import DataExplorer from 'src/data_explorer'
import {DashboardsPage, DashboardPage} from 'src/dashboards'
import {CreateSource, SourcePage, ManageSources} from 'src/sources'
import {AdminPage} from 'src/admin'
import {CreateSource, SourcePage, ManageSources} from 'src/sources'
import NotFound from 'shared/components/NotFound'
import configureStore from 'src/store/configureStore'
import {loadLocalStorage} from './localStorage'
import {getMe} from 'shared/apis'
@ -34,6 +36,7 @@ import {
meReceived,
logoutLinkReceived,
} from 'shared/actions/auth'
import {linksReceived} from 'shared/actions/links'
import {errorThrown} from 'shared/actions/errors'
import 'src/style/chronograf.scss'
@ -85,11 +88,13 @@ const Root = React.createClass({
async startHeartbeat({shouldDispatchResponse}) {
try {
const {data: me, auth, logoutLink} = await getMe()
// These non-me objects are added to every response by some AJAX trickery
const {data: me, auth, logoutLink, external} = await getMe()
if (shouldDispatchResponse) {
dispatch(authReceived(auth))
dispatch(meReceived(me))
dispatch(logoutLinkReceived(logoutLink))
dispatch(linksReceived({external}))
}
setTimeout(() => {
@ -122,22 +127,23 @@ const Root = React.createClass({
/>
<Route path="/sources/:sourceID" component={UserIsAuthenticated(App)}>
<Route component={CheckSources}>
<Route path="manage-sources" component={ManageSources} />
<Route path="manage-sources/new" component={SourcePage} />
<Route path="manage-sources/:id/edit" component={SourcePage} />
<Route path="chronograf/data-explorer" component={DataExplorer} />
<Route path="status" component={StatusPage} />
<Route path="hosts" component={HostsPage} />
<Route path="hosts/:hostID" component={HostPage} />
<Route path="kapacitors/new" component={KapacitorPage} />
<Route path="kapacitors/:id/edit" component={KapacitorPage} />
<Route path="kapacitor-tasks" component={KapacitorTasksPage} />
<Route path="alerts" component={AlertsApp} />
<Route path="chronograf/data-explorer" component={DataExplorer} />
<Route path="dashboards" component={DashboardsPage} />
<Route path="dashboards/:dashboardID" component={DashboardPage} />
<Route path="alerts" component={AlertsApp} />
<Route path="alert-rules" component={KapacitorRulesPage} />
<Route path="alert-rules/:ruleID" component={KapacitorRulePage} />
<Route path="alert-rules/new" component={KapacitorRulePage} />
<Route path="kapacitors/new" component={KapacitorPage} />
<Route path="kapacitors/:id/edit" component={KapacitorPage} />
<Route path="kapacitor-tasks" component={KapacitorTasksPage} />
<Route path="admin" component={AdminPage} />
<Route path="manage-sources" component={ManageSources} />
<Route path="manage-sources/new" component={SourcePage} />
<Route path="manage-sources/:id/edit" component={SourcePage} />
</Route>
</Route>
<Route path="*" component={NotFound} />

View File

@ -29,9 +29,8 @@ export const KapacitorRule = React.createClass({
},
getInitialState() {
const fifteenMinutesIndex = 1
return {
timeRange: timeRanges[fifteenMinutesIndex],
timeRange: timeRanges.find(tr => tr.lower === 'now() - 15m'),
}
},

View File

@ -0,0 +1,6 @@
import * as actionTypes from 'shared/constants/actionTypes'
export const linksReceived = links => ({
type: actionTypes.LINKS_RECEIVED,
payload: {links},
})

View File

@ -2,7 +2,7 @@ import React, {PropTypes} from 'react'
import classnames from 'classnames'
import OnClickOutside from 'shared/components/OnClickOutside'
import autoRefreshItems from 'hson!../data/autoRefreshes.hson'
import autoRefreshItems from 'hson!shared/data/autoRefreshes.hson'
const {number, func} = PropTypes

View File

@ -321,12 +321,13 @@ export default class Dygraph extends Component {
render() {
return (
<div style={{height: '100%'}}>
<div className="dygraph-child">
<div
ref={r => {
this.graphContainer = r
}}
style={this.props.containerStyle}
className="dygraph-child-container"
/>
<div
ref={r => {

View File

@ -1,56 +1,45 @@
import React, {PropTypes} from 'react'
import AutoRefresh from 'shared/components/AutoRefresh'
import LineGraph from 'shared/components/LineGraph'
import SingleStat from 'shared/components/SingleStat'
import NameableGraph from 'shared/components/NameableGraph'
import React, {Component, PropTypes} from 'react'
import ReactGridLayout, {WidthProvider} from 'react-grid-layout'
import timeRanges from 'hson!../data/timeRanges.hson'
import NameableGraph from 'shared/components/NameableGraph'
import RefreshingGraph from 'shared/components/RefreshingGraph'
import AlertsApp from 'src/alerts/containers/AlertsApp'
import NewsFeed from 'src/status/components/NewsFeed'
import GettingStarted from 'src/status/components/GettingStarted'
import timeRanges from 'hson!shared/data/timeRanges.hson'
import buildInfluxQLQuery from 'utils/influxql'
import {
// TODO: get these const values dynamically
STATUS_PAGE_ROW_COUNT,
PAGE_HEADER_HEIGHT,
PAGE_CONTAINER_MARGIN,
LAYOUT_MARGIN,
DASHBOARD_LAYOUT_ROW_HEIGHT,
} from 'shared/constants'
import {RECENT_ALERTS_LIMIT} from 'src/status/constants'
const GridLayout = WidthProvider(ReactGridLayout)
const RefreshingLineGraph = AutoRefresh(LineGraph)
const RefreshingSingleStat = AutoRefresh(SingleStat)
class LayoutRenderer extends Component {
constructor(props) {
super(props)
const {arrayOf, bool, func, number, shape, string} = PropTypes
this.state = {
rowHeight: this.calculateRowHeight(),
}
export const LayoutRenderer = React.createClass({
propTypes: {
autoRefresh: number.isRequired,
timeRange: shape({
lower: string.isRequired,
}),
cells: arrayOf(
shape({
queries: arrayOf(
shape({
label: string,
text: string,
query: string,
}).isRequired
).isRequired,
x: number.isRequired,
y: number.isRequired,
w: number.isRequired,
h: number.isRequired,
i: string.isRequired,
name: string.isRequired,
type: string.isRequired,
}).isRequired
),
templates: arrayOf(shape()),
host: string,
source: string,
onPositionChange: func,
onEditCell: func,
onRenameCell: func,
onUpdateCell: func,
onDeleteCell: func,
onSummonOverlayTechnologies: func,
shouldNotBeEditable: bool,
synchronizer: func,
},
this.buildQueryForOldQuerySchema = ::this.buildQueryForOldQuerySchema
this.standardizeQueries = ::this.standardizeQueries
this.generateWidgetCell = ::this.generateWidgetCell
this.generateVisualizations = ::this.generateVisualizations
this.handleLayoutChange = ::this.handleLayoutChange
this.triggerWindowResize = ::this.triggerWindowResize
this.calculateRowHeight = ::this.calculateRowHeight
this.updateWindowDimensions = ::this.updateWindowDimensions
}
buildQueryForOldQuerySchema(q) {
const {timeRange: {lower}, host} = this.props
@ -82,42 +71,59 @@ export const LayoutRenderer = React.createClass({
}
return text
},
}
renderRefreshingGraph(type, queries, cellHeight) {
const {timeRange, autoRefresh, templates, synchronizer} = this.props
standardizeQueries(cell, source) {
return cell.queries.map(query => {
// TODO: Canned dashboards (and possibly Kubernetes dashboard) use an old query schema,
// which does not have enough information for the new `buildInfluxQLQuery` function
// to operate on. We will use `buildQueryForOldQuerySchema` until we conform
// on a stable query representation.
let queryText
if (query.queryConfig) {
const {queryConfig: {rawText, range}} = query
const timeRange = range || {upper: null, lower: ':dashboardTime:'}
queryText = rawText || buildInfluxQLQuery(timeRange, query.queryConfig)
} else {
queryText = this.buildQueryForOldQuerySchema(query)
}
if (type === 'single-stat') {
return (
<RefreshingSingleStat
queries={[queries[0]]}
templates={templates}
autoRefresh={autoRefresh}
cellHeight={cellHeight}
/>
)
return Object.assign({}, query, {
host: source.links.proxy,
text: queryText,
})
})
}
generateWidgetCell(cell) {
const {source, timeRange} = this.props
switch (cell.type) {
case 'alerts': {
return (
<AlertsApp
source={source}
timeRange={timeRange}
isWidget={true}
limit={RECENT_ALERTS_LIMIT}
/>
)
}
case 'news': {
return <NewsFeed source={source} />
}
case 'guide': {
return <GettingStarted />
}
}
const displayOptions = {
stepPlot: type === 'line-stepplot',
stackedGraph: type === 'line-stacked',
}
return (
<RefreshingLineGraph
queries={queries}
templates={templates}
timeRange={timeRange}
autoRefresh={autoRefresh}
showSingleStat={type === 'line-plus-single-stat'}
isBarGraph={type === 'bar'}
displayOptions={displayOptions}
synchronizer={synchronizer}
cellHeight={cellHeight}
/>
<div className="graph-empty">
<p>No Results</p>
</div>
)
},
}
// Generates cell contents based on cell type, i.e. graphs, news feeds, etc.
generateVisualizations() {
const {
source,
@ -128,29 +134,14 @@ export const LayoutRenderer = React.createClass({
onDeleteCell,
onSummonOverlayTechnologies,
shouldNotBeEditable,
timeRange,
autoRefresh,
templates,
synchronizer,
} = this.props
return cells.map(cell => {
const queries = cell.queries.map(query => {
// TODO: Canned dashboards (and possibly Kubernetes dashboard) use an old query schema,
// which does not have enough information for the new `buildInfluxQLQuery` function
// to operate on. We will use `buildQueryForOldQuerySchema` until we conform
// on a stable query representation.
let queryText
if (query.queryConfig) {
const {queryConfig: {rawText, range}} = query
const timeRange = range || {upper: null, lower: ':dashboardTime:'}
queryText =
rawText || buildInfluxQLQuery(timeRange, query.queryConfig)
} else {
queryText = this.buildQueryForOldQuerySchema(query)
}
return Object.assign({}, query, {
host: source,
text: queryText,
})
})
const {type, h} = cell
return (
<div key={cell.i}>
@ -163,12 +154,22 @@ export const LayoutRenderer = React.createClass({
shouldNotBeEditable={shouldNotBeEditable}
cell={cell}
>
{this.renderRefreshingGraph(cell.type, queries, cell.h)}
{cell.isWidget
? this.generateWidgetCell(cell)
: <RefreshingGraph
timeRange={timeRange}
autoRefresh={autoRefresh}
templates={templates}
synchronizer={synchronizer}
type={type}
queries={this.standardizeQueries(cell, source)}
cellHeight={h}
/>}
</NameableGraph>
</div>
)
})
},
}
handleLayoutChange(layout) {
this.triggerWindowResize()
@ -184,18 +185,46 @@ export const LayoutRenderer = React.createClass({
})
this.props.onPositionChange(newCells)
},
}
triggerWindowResize() {
// Hack to get dygraphs to fit properly during and after resize (dispatchEvent is a global method on window).
const evt = document.createEvent('CustomEvent') // MUST be 'CustomEvent'
evt.initCustomEvent('resize', false, false, null)
dispatchEvent(evt)
}
// ensures that Status Page height fits the window
calculateRowHeight() {
const {isStatusPage} = this.props
return isStatusPage
? (window.innerHeight -
STATUS_PAGE_ROW_COUNT * LAYOUT_MARGIN -
PAGE_HEADER_HEIGHT -
PAGE_CONTAINER_MARGIN -
PAGE_CONTAINER_MARGIN) /
STATUS_PAGE_ROW_COUNT
: DASHBOARD_LAYOUT_ROW_HEIGHT
}
// idea adopted from https://stackoverflow.com/questions/36862334/get-viewport-window-height-in-reactjs
updateWindowDimensions() {
this.setState({rowHeight: this.calculateRowHeight()})
}
render() {
const layoutMargin = 4
const {cells} = this.props
const {rowHeight} = this.state
const isDashboard = !!this.props.onPositionChange
return (
<GridLayout
layout={this.props.cells}
layout={cells}
cols={12}
rowHeight={83.5}
margin={[layoutMargin, layoutMargin]}
rowHeight={rowHeight}
margin={[LAYOUT_MARGIN, LAYOUT_MARGIN]}
containerPadding={[0, 0]}
useCSSTransforms={false}
onResize={this.triggerWindowResize}
@ -207,14 +236,60 @@ export const LayoutRenderer = React.createClass({
{this.generateVisualizations()}
</GridLayout>
)
},
}
triggerWindowResize() {
// Hack to get dygraphs to fit properly during and after resize (dispatchEvent is a global method on window).
const evt = document.createEvent('CustomEvent') // MUST be 'CustomEvent'
evt.initCustomEvent('resize', false, false, null)
dispatchEvent(evt)
},
})
componentDidMount() {
window.addEventListener('resize', this.updateWindowDimensions)
}
componentWillUnmount() {
window.removeEventListener('resize', this.updateWindowDimensions)
}
}
const {arrayOf, bool, func, number, shape, string} = PropTypes
LayoutRenderer.propTypes = {
autoRefresh: number.isRequired,
timeRange: shape({
lower: string.isRequired,
}),
cells: arrayOf(
shape({
// isWidget cells will not have queries
isWidget: bool,
queries: arrayOf(
shape({
label: string,
text: string,
query: string,
}).isRequired
),
x: number.isRequired,
y: number.isRequired,
w: number.isRequired,
h: number.isRequired,
i: string.isRequired,
name: string.isRequired,
type: string.isRequired,
}).isRequired
),
templates: arrayOf(shape()),
host: string,
source: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}).isRequired,
onPositionChange: func,
onEditCell: func,
onRenameCell: func,
onUpdateCell: func,
onDeleteCell: func,
onSummonOverlayTechnologies: func,
shouldNotBeEditable: bool,
synchronizer: func,
isStatusPage: bool,
}
export default LayoutRenderer

View File

@ -11,12 +11,14 @@ const NoKapacitorError = React.createClass({
render() {
const path = `/sources/${this.props.source.id}/kapacitors/new`
return (
<div>
<div className="graph-empty">
<p>
The current source does not have an associated Kapacitor instance,
please configure one.
The current source does not have an associated Kapacitor instance
<br /><br />
<Link to={path} className="btn btn-sm btn-primary">
Configure Kapacitor
</Link>
</p>
<Link to={path}>Add Kapacitor</Link>
</div>
)
},

View File

@ -0,0 +1,63 @@
import React, {PropTypes} from 'react'
import AutoRefresh from 'shared/components/AutoRefresh'
import LineGraph from 'shared/components/LineGraph'
import SingleStat from 'shared/components/SingleStat'
const RefreshingLineGraph = AutoRefresh(LineGraph)
const RefreshingSingleStat = AutoRefresh(SingleStat)
const RefreshingGraph = ({
timeRange,
autoRefresh,
templates,
synchronizer,
type,
queries,
cellHeight,
}) => {
if (type === 'single-stat') {
return (
<RefreshingSingleStat
queries={[queries[0]]}
templates={templates}
autoRefresh={autoRefresh}
cellHeight={cellHeight}
/>
)
}
const displayOptions = {
stepPlot: type === 'line-stepplot',
stackedGraph: type === 'line-stacked',
}
return (
<RefreshingLineGraph
queries={queries}
templates={templates}
timeRange={timeRange}
autoRefresh={autoRefresh}
showSingleStat={type === 'line-plus-single-stat'}
isBarGraph={type === 'bar'}
displayOptions={displayOptions}
synchronizer={synchronizer}
/>
)
}
const {arrayOf, func, number, shape, string} = PropTypes
RefreshingGraph.propTypes = {
timeRange: shape({
lower: string.isRequired,
}),
autoRefresh: number.isRequired,
templates: arrayOf(shape()),
synchronizer: func,
type: string.isRequired,
queries: arrayOf(shape()).isRequired,
cellHeight: number.isRequired,
}
export default RefreshingGraph

View File

@ -5,7 +5,7 @@ import moment from 'moment'
import OnClickOutside from 'shared/components/OnClickOutside'
import FancyScrollbar from 'shared/components/FancyScrollbar'
import timeRanges from 'hson!../data/timeRanges.hson'
import timeRanges from 'hson!shared/data/timeRanges.hson'
import {DROPDOWN_MENU_MAX_HEIGHT} from 'shared/constants/index'
const TimeRangeDropdown = React.createClass({

View File

@ -1 +1,3 @@
export const TEMPLATE_VARIABLE_SELECTED = 'TEMPLATE_VARIABLE_SELECTED'
export const LINKS_RECEIVED = 'LINKS_RECEIVED'

View File

@ -403,3 +403,11 @@ export const VIS_VIEWS = [GRAPH, TABLE]
// InfluxQL Macros
export const TEMP_VAR_INTERVAL = ':interval:'
export const DEFAULT_DASHBOARD_GROUP_BY_INTERVAL = 'auto'
export const DEFAULT_HOME_PAGE = 'status'
export const STATUS_PAGE_ROW_COUNT = 10 // TODO: calculate based on actual Status Page cells
export const PAGE_HEADER_HEIGHT = 60 // TODO: get this dynamically to ensure longevity
export const PAGE_CONTAINER_MARGIN = 30 // TODO: get this dynamically to ensure longevity
export const LAYOUT_MARGIN = 4
export const DASHBOARD_LAYOUT_ROW_HEIGHT = 83.5

View File

@ -8,4 +8,5 @@
{defaultGroupBy: '30m', seconds: 172800, inputValue: 'Past 2 days', lower: 'now() - 2d', upper: null, menuOption: 'Past 2 days'},
{defaultGroupBy: '1h', seconds: 604800, inputValue: 'Past 7 days', lower: 'now() - 7d', upper: null, menuOption: 'Past 7 days'},
{defaultGroupBy: '6h', seconds: 2592000, inputValue: 'Past 30 days', lower: 'now() - 30d', upper: null, menuOption: 'Past 30 days'},
{defaultGroupBy: '12h', seconds: 7776000, inputValue: 'Past 90 days', lower: 'now() - 90d', upper: null, menuOption: 'Past 90 days'},
]

View File

@ -1,6 +1,7 @@
import app from './app'
import auth from './auth'
import errors from './errors'
import links from './links'
import notifications from './notifications'
import sources from './sources'
@ -8,6 +9,7 @@ export default {
app,
auth,
errors,
links,
notifications,
sources,
}

View File

@ -0,0 +1,21 @@
import * as actionTypes from 'shared/constants/actionTypes'
const initialState = {
external: {statusFeed: ''},
}
const linksReducer = (state = initialState, action) => {
switch (action.type) {
case actionTypes.LINKS_RECEIVED: {
const {links} = action.payload
return links
}
default: {
return state
}
}
}
export default linksReducer

View File

@ -9,6 +9,8 @@ import {
NavListItem,
} from 'src/side_nav/components/NavItems'
import {DEFAULT_HOME_PAGE} from 'shared/constants'
const {bool, shape, string} = PropTypes
const SideNav = React.createClass({
@ -38,9 +40,12 @@ const SideNav = React.createClass({
return isHidden
? null
: <NavBar location={location}>
<div className="sidebar__logo">
<Link to="/"><span className="icon cubo-uniform" /></Link>
</div>
<Link
to={`${sourcePrefix}/${DEFAULT_HOME_PAGE}`}
className="sidebar__logo"
>
<span className="icon cubo-uniform" />
</Link>
<NavBlock icon="cubo-node" link={`${sourcePrefix}/hosts`}>
<NavHeader link={`${sourcePrefix}/hosts`} title="Host List" />
</NavBlock>

View File

@ -0,0 +1,59 @@
// he is a library for safely encoding and decoding HTML Entities
import he from 'he'
import {fetchJSONFeed as fetchJSONFeedAJAX} from 'src/status/apis'
import {errorThrown} from 'shared/actions/errors'
import * as actionTypes from 'src/status/constants/actionTypes'
import {HTTP_NOT_FOUND} from 'shared/constants'
const fetchJSONFeedRequested = () => ({
type: actionTypes.FETCH_JSON_FEED_REQUESTED,
})
const fetchJSONFeedCompleted = data => ({
type: actionTypes.FETCH_JSON_FEED_COMPLETED,
payload: {data},
})
const fetchJSONFeedFailed = () => ({
type: actionTypes.FETCH_JSON_FEED_FAILED,
})
export const fetchJSONFeedAsync = url => async dispatch => {
dispatch(fetchJSONFeedRequested())
try {
const {data} = await fetchJSONFeedAJAX(url)
// data could be from a webpage, and thus would be HTML
if (typeof data === 'string' || !data) {
dispatch(fetchJSONFeedFailed())
} else {
// decode HTML entities from response text
const decodedData = {
...data,
items: data.items.map(item => {
item.title = he.decode(item.title)
item.content_text = he.decode(item.content_text)
return item
}),
}
dispatch(fetchJSONFeedCompleted(decodedData))
}
} catch (error) {
console.error(error)
dispatch(fetchJSONFeedFailed())
if (error.status === HTTP_NOT_FOUND) {
dispatch(
errorThrown(
error,
`Failed to fetch News Feed. JSON Feed at '${url}' returned 404 (Not Found)`
)
)
} else {
dispatch(errorThrown(error, 'Failed to fetch NewsFeed'))
}
}
}

View File

@ -0,0 +1,10 @@
import AJAX from 'utils/ajax'
export const fetchJSONFeed = url =>
AJAX({
method: 'GET',
url,
// For explanation of why this header makes this work:
// https://stackoverflow.com/questions/22968406/how-to-skip-the-options-preflight-request-in-angularjs
headers: {'Content-Type': 'text/plain; charset=UTF-8'},
})

View File

@ -0,0 +1,95 @@
import React, {Component} from 'react'
import FancyScrollbar from 'shared/components/FancyScrollbar'
class GettingStarted extends Component {
constructor(props) {
super(props)
}
render() {
return (
<FancyScrollbar className="getting-started--container">
<div className="getting-started">
<div className="getting-started--cell intro">
<h5>
<span className="icon cubo-uniform" /> Welcome to Chronograf!
</h5>
<p>Follow the links below to explore Chronografs features.</p>
</div>
<div className="getting-started--cell">
<p>
<strong>Install the TICK Stack</strong><br />Save some time and
use this handy tool to install the rest of the
stack:
</p>
<p>
<a href="https://github.com/influxdata/sandbox" target="_blank">
<span className="icon github" /> TICK Sandbox
</a>
</p>
</div>
<div className="getting-started--cell">
<p><strong>Guides</strong></p>
<p>
<a
href="https://docs.influxdata.com/chronograf/latest/guides/create-a-dashboard/"
target="_blank"
>
Create a Dashboard
</a>
<br />
<a
href="https://docs.influxdata.com/chronograf/latest/guides/create-a-kapacitor-alert/"
target="_blank"
>
Create a Kapacitor Alert
</a>
<br />
<a
href="https://docs.influxdata.com/chronograf/latest/guides/configure-kapacitor-event-handlers/"
target="_blank"
>
Configure Kapacitor Event Handlers
</a>
<br />
<a
href="https://docs.influxdata.com/chronograf/latest/guides/transition-web-admin-interface/"
target="_blank"
>
Transition from InfluxDB's Web Admin Interface
</a>
<br />
<a
href="https://docs.influxdata.com/chronograf/latest/guides/dashboard-template-variables/"
target="_blank"
>
Dashboard Template Variables
</a>
<br />
<a
href="https://docs.influxdata.com/chronograf/latest/guides/advanced-kapacitor/"
target="_blank"
>
Advanced Kapacitor Usage
</a>
</p>
</div>
<div className="getting-started--cell">
<p><strong>Questions & Comments</strong></p>
<p>
If you have any product feedback please open a GitHub issue and
we'll take a look. For any questions or other issues try posting
on our&nbsp;
<a href="https://community.influxdata.com/" target="_blank">
Community Forum
</a>.
</p>
</div>
</div>
</FancyScrollbar>
)
}
}
export default GettingStarted

View File

@ -0,0 +1,59 @@
import React, {PropTypes} from 'react'
import moment from 'moment'
const JSONFeedReader = ({data}) =>
data && data.items
? <div className="newsfeed">
{data.items
? data.items.map(
({
id,
date_published,
url,
title,
author: {name},
image,
content_text: contentText,
}) =>
<div key={id} className="newsfeed--post">
<div className="newsfeed--date">
{`${moment(date_published).format('MMM DD')}`}
</div>
<div className="newsfeed--post-title">
<a href={url} target="_blank">
<h6>{title}</h6>
</a>
<span>by {name}</span>
</div>
<div className="newsfeed--content">
{image ? <img src={image} /> : null}
<p>{contentText}</p>
</div>
</div>
)
: null}
</div>
: null
const {arrayOf, shape, string} = PropTypes
JSONFeedReader.propTypes = {
data: shape({
items: arrayOf(
shape({
author: shape({
name: string.isRequired,
}).isRequired,
content_text: string.isRequired,
date_published: string.isRequired,
id: string.isRequired,
image: string,
title: string.isRequired,
url: string.isRequired,
})
),
}).isRequired,
}
export default JSONFeedReader

View File

@ -0,0 +1,85 @@
import React, {Component, PropTypes} from 'react'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import {fetchJSONFeedAsync} from 'src/status/actions'
import FancyScrollbar from 'shared/components/FancyScrollbar'
import JSONFeedReader from 'src/status/components/JSONFeedReader'
class NewsFeed extends Component {
constructor(props) {
super(props)
}
// TODO: implement shouldComponentUpdate based on fetching conditions
render() {
const {hasCompletedFetchOnce, isFetching, isFailed, data} = this.props
if (!hasCompletedFetchOnce) {
return isFailed
? <div className="graph-empty">
<p>Failed to load News Feed</p>
</div>
: // TODO: Factor this out of here and AutoRefresh
<div className="graph-fetching">
<div className="graph-spinner" />
</div>
}
return (
<FancyScrollbar autoHide={false} className="newsfeed--container">
{isFetching
? // TODO: Factor this out of here and AutoRefresh
<div className="graph-panel__refreshing">
<div />
<div />
<div />
</div>
: null}
{isFailed
? <div className="graph-empty">
<p>Failed to refresh News Feed</p>
</div>
: null}
<JSONFeedReader data={data} />
</FancyScrollbar>
)
}
// TODO: implement interval polling a la AutoRefresh
componentDidMount() {
const {statusFeedURL, fetchJSONFeed} = this.props
fetchJSONFeed(statusFeedURL)
}
}
const {bool, func, shape, string} = PropTypes
NewsFeed.propTypes = {
hasCompletedFetchOnce: bool.isRequired,
isFetching: bool.isRequired,
isFailed: bool.isRequired,
data: shape(),
fetchJSONFeed: func.isRequired,
statusFeedURL: string,
}
const mapStateToProps = ({
links: {external: {statusFeed: statusFeedURL}},
JSONFeed: {hasCompletedFetchOnce, isFetching, isFailed, data},
}) => ({
hasCompletedFetchOnce,
isFetching,
isFailed,
data,
statusFeedURL,
})
const mapDispatchToProps = dispatch => ({
fetchJSONFeed: bindActionCreators(fetchJSONFeedAsync, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(NewsFeed)

View File

@ -0,0 +1,6 @@
export const SET_STATUS_PAGE_AUTOREFRESH = 'SET_STATUS_PAGE_AUTOREFRESH'
export const SET_STATUS_PAGE_TIME_RANGE = 'SET_STATUS_PAGE_TIME_RANGE'
export const FETCH_JSON_FEED_REQUESTED = 'FETCH_JSON_FEED_REQUESTED'
export const FETCH_JSON_FEED_COMPLETED = 'FETCH_JSON_FEED_COMPLETED'
export const FETCH_JSON_FEED_FAILED = 'FETCH_JSON_FEED_FAILED'

View File

@ -0,0 +1 @@
export const RECENT_ALERTS_LIMIT = 30

View File

@ -0,0 +1,91 @@
import React, {Component, PropTypes} from 'react'
import {connect} from 'react-redux'
import SourceIndicator from 'shared/components/SourceIndicator'
import FancyScrollbar from 'shared/components/FancyScrollbar'
import LayoutRenderer from 'shared/components/LayoutRenderer'
import {fixtureStatusPageCells} from 'src/status/fixtures'
class StatusPage extends Component {
constructor(props) {
super(props)
this.state = {
cells: fixtureStatusPageCells,
}
}
render() {
const {source, autoRefresh, timeRange} = this.props
const {cells} = this.state
const dashboardTime = {
id: 'dashtime',
tempVar: ':dashboardTime:',
type: 'constant',
values: [
{
value: timeRange.lower,
type: 'constant',
selected: true,
},
],
}
const templates = [dashboardTime]
return (
<div className="page">
<div className="page-header full-width">
<div className="page-header__container">
<div className="page-header__left">
<h1 className="page-header__title">
Status
</h1>
</div>
<div className="page-header__right">
<SourceIndicator sourceName={source.name} />
</div>
</div>
</div>
<FancyScrollbar className="page-contents">
<div className="dashboard container-fluid full-width">
{cells.length
? <LayoutRenderer
autoRefresh={autoRefresh}
timeRange={timeRange}
cells={cells}
templates={templates}
source={source}
shouldNotBeEditable={true}
isStatusPage={true}
/>
: <span>Loading Status Page...</span>}
</div>
</FancyScrollbar>
</div>
)
}
}
const {number, shape, string} = PropTypes
StatusPage.propTypes = {
source: shape({
name: string.isRequired,
links: shape({
proxy: string.isRequired,
}).isRequired,
}).isRequired,
autoRefresh: number.isRequired,
timeRange: shape({
lower: string.isRequired,
}).isRequired,
}
const mapStateToProps = ({statusUI: {autoRefresh, timeRange}}) => ({
autoRefresh,
timeRange,
})
export default connect(mapStateToProps, null)(StatusPage)

71
ui/src/status/fixtures.js Normal file
View File

@ -0,0 +1,71 @@
export const fixtureStatusPageCells = [
{
i: 'alerts-bar-graph',
isWidget: false,
x: 0,
y: 0,
w: 12,
h: 4,
name: 'Alert Events per Day Last 30 Days',
queries: [
{
query:
'SELECT count("value") AS "count_value" FROM "chronograf"."autogen"."alerts" WHERE time > :dashboardTime: GROUP BY time(1d)',
label: 'Events',
queryConfig: {
database: 'chronograf',
measurement: 'alerts',
retentionPolicy: 'autogen',
fields: [
{
field: 'value',
funcs: ['count'],
},
],
tags: {},
groupBy: {
time: '1d',
tags: [],
},
areTagsAccepted: false,
rawText: null,
range: null,
},
},
],
type: 'bar',
links: {
self: '/chronograf/v1/status/23/cells/c-bar-graphs-fly',
},
},
{
i: 'recent-alerts',
isWidget: true,
name: 'Alerts Last 30 Days',
type: 'alerts',
x: 0,
y: 5,
w: 6.5,
h: 6,
},
{
i: 'news-feed',
isWidget: true,
name: 'News Feed',
type: 'news',
x: 6.5,
y: 5,
w: 3,
h: 6,
},
{
i: 'getting-started',
isWidget: true,
name: 'Getting Started',
type: 'guide',
x: 9.5,
y: 5,
w: 2.5,
h: 6,
},
]

3
ui/src/status/index.js Normal file
View File

@ -0,0 +1,3 @@
import StatusPage from 'src/status/containers/StatusPage'
export {StatusPage}

View File

@ -0,0 +1,43 @@
import * as actionTypes from 'src/status/constants/actionTypes'
const initialState = {
hasCompletedFetchOnce: false,
isFetching: false,
isFailed: false,
data: null,
}
const JSONFeedReducer = (state = initialState, action) => {
switch (action.type) {
case actionTypes.FETCH_JSON_FEED_REQUESTED: {
return {...state, isFetching: true, isFailed: false}
}
case actionTypes.FETCH_JSON_FEED_COMPLETED: {
const {data} = action.payload
return {
...state,
hasCompletedFetchOnce: true,
isFetching: false,
isFailed: false,
data,
}
}
case actionTypes.FETCH_JSON_FEED_FAILED: {
return {
...state,
isFetching: false,
isFailed: true,
data: null,
}
}
default: {
return state
}
}
}
export default JSONFeedReducer

View File

@ -0,0 +1,7 @@
import statusUI from './ui'
import JSONFeed from './JSONFeed'
export default {
statusUI,
JSONFeed,
}

View File

@ -0,0 +1,36 @@
import {AUTOREFRESH_DEFAULT} from 'shared/constants'
import timeRanges from 'hson!shared/data/timeRanges.hson'
import * as actionTypes from 'src/status/constants/actionTypes'
const {lower, upper} = timeRanges.find(tr => tr.lower === 'now() - 90d')
const initialState = {
autoRefresh: AUTOREFRESH_DEFAULT,
timeRange: {lower, upper},
}
const statusUI = (state = initialState, action) => {
switch (action.type) {
case actionTypes.SET_STATUS_PAGE_AUTOREFRESH: {
const {milliseconds} = action.payload
return {
...state,
autoRefresh: milliseconds,
}
}
case actionTypes.SET_STATUS_PAGE_TIME_RANGE: {
const {timeRange} = action.payload
return {...state, timeRange}
}
default: {
return state
}
}
}
export default statusUI

View File

@ -5,14 +5,16 @@ import thunkMiddleware from 'redux-thunk'
import errorsMiddleware from 'shared/middleware/errors'
import resizeLayout from 'shared/middleware/resizeLayout'
import adminReducer from 'src/admin/reducers/admin'
import statusReducers from 'src/status/reducers'
import sharedReducers from 'shared/reducers'
import dataExplorerReducers from 'src/data_explorer/reducers'
import adminReducer from 'src/admin/reducers/admin'
import rulesReducer from 'src/kapacitor/reducers/rules'
import dashboardUI from 'src/dashboards/reducers/ui'
import persistStateEnhancer from './persistStateEnhancer'
const rootReducer = combineReducers({
...statusReducers,
...sharedReducers,
...dataExplorerReducers,
admin: adminReducer,

View File

@ -28,10 +28,13 @@
@import 'components/confirm-buttons';
@import 'components/custom-time-range';
@import 'components/dygraphs';
@import 'components/fancy-scrollbars';
@import 'components/flip-toggle';
@import 'components/function-selector';
@import 'components/graph-tips';
@import 'components/graph';
@import 'components/input-tag-list';
@import 'components/newsfeed';
@import 'components/page-header-dropdown';
@import 'components/page-header-editable';
@import 'components/page-spinner';
@ -42,8 +45,6 @@
@import 'components/search-widget';
@import 'components/source-indicator';
@import 'components/tables';
@import 'components/function-selector';
@import 'components/fancy-scrollbars';
// Pages

View File

@ -66,18 +66,18 @@ $graph-gutter: 16px;
.graph-container {
@include no-user-select();
& > div:not(.graph-panel__refreshing) {
& > .dygraph {
position: absolute;
width: 100%;
height: 100%;
}
& > div:not(.graph-panel__refreshing) > div:not(.graph-panel__refreshing) {
& > .dygraph > .dygraph-child {
position: absolute;
width: 100%;
height: 100%;
padding: 8px 16px;
}
& > div:not(.graph-panel__refreshing) > div:not(.graph-panel__refreshing) > div:first-child {
& > .dygraph > .dygraph-child > .dygraph-child-container {
height: 100% !important;
}
}

View File

@ -0,0 +1,66 @@
/*
Newsfeed Cell (Status Dash)
------------------------------------------------------
*/
.newsfeed--container {
.fancy-scroll--track-h {
display: none;
}
}
.newsfeed {
padding: 0 16px;
}
.newsfeed--post {
position: relative;
margin-bottom: 4px;
background-color: $g2-kevlar;
border-radius: 3px;
&:last-child {
margin-bottom: 0;
}
}
.newsfeed--post-title {
font-size: 14px;
font-weight: 700;
color: $g18-cloud;
padding: 12px;
padding-right: 76px;
}
.newsfeed--date {
position: absolute;
top: 0;
right: 0;
height: 22px;
line-height: 20px;
background-color: $g3-castle;
color: $g10-wolf;
font-size: 13px;
font-weight: 900;
width: 56px;
text-align: center;
border-bottom-left-radius: 8px;
}
.newsfeed--content {
padding: 12px;
/* Condensing default typography styles to better suit the context */
p {margin-top: 0;}
p, li {font-size: 13px;}
blockquote {
margin: 0 0 8px 0;
padding: 0 12px;
&:before, &:after {content: none;}
}
ol, ul {margin: 0 0 8px 0;}
li {padding-left: 0;}
hr {margin: 8px 0;}
img {
width: calc(100% - 48px);
margin: 8px 24px;
border-radius: 3px;
}
}

View File

@ -1,26 +1,36 @@
.page-spinner {
border: 0.3em solid rgba(51, 51, 51, 0.4);
border: 4px solid rgba(51, 51, 51, 0.4);
border-left-color: $c-pool;
border-radius: 50%;
width: 8em;
height: 8em;
position: fixed;
margin: auto;
top: 0;
left: $sidebar-width; // Center the spinner based on the main content window, not the entire screen
bottom: 0;
right: 0;
/* This is a hack to tell the browser to use the GPU when rendering the spinner. */
transform: translateZ(0);
animation: spinner 0.6s infinite linear;
width: 100px;
height: 100px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
animation: pageSpinner 0.8s infinite linear;
}
.chronograf-root > .page-spinner {
// Center the spinner based on the main content window, not the entire screen
left: calc(50% + #{$sidebar-width});
}
@keyframes spinner {
@keyframes pageSpinner {
0% {
transform: rotate(0deg);
transform: translate(-50%,-50%) rotate(0deg);
border-left-color: $c-pool;
}
25% {
border-left-color: $c-comet;
}
50% {
border-left-color: $c-pool;
}
75% {
border-left-color: $c-rainforest;
}
100% {
transform: rotate(360deg);
transform: translate(-50%,-50%) rotate(360deg);
border-left-color: $c-pool;
}
}

View File

@ -50,7 +50,7 @@
Sortable Tables
----------------------------------------------
*/
.sortable-header {
table.table thead th.sortable-header {
transition:
color 0.25s ease,
background-color 0.25s ease;
@ -82,9 +82,7 @@
background-color: $g5-pepper;
color: $g19-ghost;
&:after {
opacity: 1;
}
&:after {opacity: 1;}
}
&.sorting-ascending:after {
transform: translateY(-50%) rotate(180deg);

View File

@ -87,19 +87,19 @@ $dash-graph-options-arrow: 8px;
left: 0;
padding: 0;
& > div:not(.graph-empty) {
& > .dygraph {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
& > div:not(.graph-panel__refreshing) {
position: absolute;
width: 100%;
height: 100%;
padding: 8px 16px;
}
}
& > .dygraph > .dygraph-child {
position: absolute;
width: 100%;
height: 100%;
padding: 8px 16px;
}
.graph-panel__refreshing {
top: (-$dash-graph-heading + 5px) !important;

View File

@ -135,7 +135,6 @@
}
}
}
.currently-connected-source {
color: $c-rainforest;
font-weight: 600;
@ -169,3 +168,27 @@ br {
border-left: 2px solid $g5-pepper;
width: 278px;
}
/*
Styles for the Status Dashboard
-----------------------------------------------------------------------------
Not enough of these to merit their own page, will organize later
*/
.alerts-widget,
.getting-started {
padding: 0 16px;
}
.getting-started--cell {
color: $g11-sidewalk;
background-color: $g2-kevlar;
border-radius: 3px;
padding: 12px;
margin-bottom: 4px;
&.intro {
@include gradient-h($c-pool,$c-star);
color: $g20-white;
}
p {font-size: 13px;}
}

View File

@ -2,7 +2,14 @@ import axios from 'axios'
let links
export default async function AJAX({
const generateResponseWithLinks = (response, {auth, logout, external}) => ({
...response,
auth: {links: auth},
logoutLink: logout,
external,
})
const AJAX = async ({
url,
resource,
id,
@ -10,7 +17,7 @@ export default async function AJAX({
data = {},
params = {},
headers = {},
}) {
}) => {
try {
const basepath = window.basepath || ''
let response
@ -39,17 +46,24 @@ export default async function AJAX({
headers,
})
const {auth} = links
return {
...response,
auth: {links: auth},
logoutLink: links.logout,
}
return generateResponseWithLinks(response, links)
} catch (error) {
const {response} = error
const {auth} = links
throw {...response, auth: {links: auth}, logout: links.logout} // eslint-disable-line no-throw-literal
throw generateResponseWithLinks(response, links) // eslint-disable-line no-throw-literal
}
}
export const get = async url => {
try {
return await AJAX({
method: 'GET',
url,
})
} catch (error) {
console.error(error)
throw error
}
}
export default AJAX

7
yarn.lock Normal file
View File

@ -0,0 +1,7 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
he@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"