Merge 'master' into 751-stack_graph, resolve conflicts

pull/10616/head
Jared Scheib 2017-02-23 14:33:07 -08:00
commit 6c67dcf402
42 changed files with 1616 additions and 346 deletions

View File

@ -5,11 +5,16 @@
2. [#907](https://github.com/influxdata/chronograf/pull/907): Fix react-router warning
### Features
1. [#873](https://github.com/influxdata/chronograf/pull/873): Add [TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) support
1. [#873](https://github.com/influxdata/chronograf/pull/873): Add [TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) support
2. [#885](https://github.com/influxdata/chronograf/issues/885): Add presentation mode to dashboard page
3. [#891](https://github.com/influxdata/chronograf/issues/891): Make dashboard visualizations draggable
4. [#892](https://github.com/influxdata/chronograf/issues/891): Make dashboard visualizations resizable
5. [#893](https://github.com/influxdata/chronograf/issues/893): Persist dashboard visualization position
### UI Improvements
1. [#905](https://github.com/influxdata/chronograf/pull/905): Make scroll bar thumb element bigger
2. [#920](https://github.com/influxdata/chronograf/pull/920): Display stacked and step plot graphs
2. [#917](https://github.com/influxdata/chronograf/pull/917): Simplify side navigation
3. [#920](https://github.com/influxdata/chronograf/pull/920): Display stacked and step plot graphs
## v1.2.0-beta3 [2017-02-15]

View File

@ -10,6 +10,13 @@ import (
"github.com/influxdata/chronograf"
)
const (
// DefaultWidth is used if not specified
DefaultWidth = 4
// DefaultHeight is used if not specified
DefaultHeight = 4
)
type dashboardLinks struct {
Self string `json:"self"` // Self link mapping to this resource
}
@ -25,6 +32,7 @@ type getDashboardsResponse struct {
func newDashboardResponse(d chronograf.Dashboard) dashboardResponse {
base := "/chronograf/v1/dashboards"
DashboardDefaults(&d)
return dashboardResponse{
Dashboard: d,
Links: dashboardLinks{
@ -80,7 +88,7 @@ func (s *Service) NewDashboard(w http.ResponseWriter, r *http.Request) {
return
}
if err := ValidDashboardRequest(dashboard); err != nil {
if err := ValidDashboardRequest(&dashboard); err != nil {
invalidData(w, err, s.Logger)
return
}
@ -119,8 +127,8 @@ func (s *Service) RemoveDashboard(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// UpdateDashboard replaces a dashboard
func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) {
// ReplaceDashboard completely replaces a dashboard
func (s *Service) ReplaceDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
idParam, err := strconv.Atoi(httprouter.GetParamFromContext(ctx, "id"))
if err != nil {
@ -142,7 +150,7 @@ func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) {
}
req.ID = id
if err := ValidDashboardRequest(req); err != nil {
if err := ValidDashboardRequest(&req); err != nil {
invalidData(w, err, s.Logger)
return
}
@ -157,17 +165,85 @@ func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) {
encodeJSON(w, http.StatusOK, res, s.Logger)
}
// UpdateDashboard completely updates either the dashboard name or the cells
func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
idParam, err := strconv.Atoi(httprouter.GetParamFromContext(ctx, "id"))
if err != nil {
msg := fmt.Sprintf("Could not parse dashboard ID: %s", err)
Error(w, http.StatusInternalServerError, msg, s.Logger)
}
id := chronograf.DashboardID(idParam)
orig, err := s.DashboardsStore.Get(ctx, id)
if err != nil {
Error(w, http.StatusNotFound, fmt.Sprintf("ID %d not found", id), s.Logger)
return
}
var req chronograf.Dashboard
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, s.Logger)
return
}
req.ID = id
if req.Name != "" {
orig.Name = req.Name
} else if len(req.Cells) > 0 {
if err := ValidDashboardRequest(&req); err != nil {
invalidData(w, err, s.Logger)
return
}
orig.Cells = req.Cells
} else {
invalidData(w, fmt.Errorf("Update must include either name or cells"), s.Logger)
return
}
if err := s.DashboardsStore.Update(ctx, orig); err != nil {
msg := fmt.Sprintf("Error updating dashboard ID %d: %v", id, err)
Error(w, http.StatusInternalServerError, msg, s.Logger)
return
}
res := newDashboardResponse(orig)
encodeJSON(w, http.StatusOK, res, s.Logger)
}
// ValidDashboardRequest verifies that the dashboard cells have a query
func ValidDashboardRequest(d chronograf.Dashboard) error {
func ValidDashboardRequest(d *chronograf.Dashboard) error {
if len(d.Cells) == 0 {
return fmt.Errorf("cells are required")
}
for _, c := range d.Cells {
if (len(c.Queries) == 0) {
for i, c := range d.Cells {
if len(c.Queries) == 0 {
return fmt.Errorf("query required")
}
CorrectWidthHeight(&c)
d.Cells[i] = c
}
DashboardDefaults(d)
return nil
}
// DashboardDefaults updates the dashboard with the default values
// if none are specified
func DashboardDefaults(d *chronograf.Dashboard) {
for i, c := range d.Cells {
CorrectWidthHeight(&c)
d.Cells[i] = c
}
}
// CorrectWidthHeight changes the cell to have at least the
// minimum width and height
func CorrectWidthHeight(c *chronograf.DashboardCell) {
if c.W < 1 {
c.W = DefaultWidth
}
if c.H < 1 {
c.H = DefaultHeight
}
}

299
server/dashboards_test.go Normal file
View File

@ -0,0 +1,299 @@
package server
import (
"reflect"
"testing"
"github.com/influxdata/chronograf"
)
func TestCorrectWidthHeight(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cell chronograf.DashboardCell
want chronograf.DashboardCell
}{
{
name: "updates width",
cell: chronograf.DashboardCell{
W: 0,
H: 4,
},
want: chronograf.DashboardCell{
W: 4,
H: 4,
},
},
{
name: "updates height",
cell: chronograf.DashboardCell{
W: 4,
H: 0,
},
want: chronograf.DashboardCell{
W: 4,
H: 4,
},
},
{
name: "updates both",
cell: chronograf.DashboardCell{
W: 0,
H: 0,
},
want: chronograf.DashboardCell{
W: 4,
H: 4,
},
},
{
name: "updates neither",
cell: chronograf.DashboardCell{
W: 4,
H: 4,
},
want: chronograf.DashboardCell{
W: 4,
H: 4,
},
},
}
for _, tt := range tests {
if CorrectWidthHeight(&tt.cell); !reflect.DeepEqual(tt.cell, tt.want) {
t.Errorf("%q. CorrectWidthHeight() = %v, want %v", tt.name, tt.cell, tt.want)
}
}
}
func TestDashboardDefaults(t *testing.T) {
tests := []struct {
name string
d chronograf.Dashboard
want chronograf.Dashboard
}{
{
name: "Updates all cell widths/heights",
d: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{
{
W: 0,
H: 0,
},
{
W: 2,
H: 2,
},
},
},
want: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{
{
W: 4,
H: 4,
},
{
W: 2,
H: 2,
},
},
},
},
{
name: "Updates no cell",
d: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{
{
W: 4,
H: 4,
}, {
W: 2,
H: 2,
},
},
},
want: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{
{
W: 4,
H: 4,
},
{
W: 2,
H: 2,
},
},
},
},
}
for _, tt := range tests {
if DashboardDefaults(&tt.d); !reflect.DeepEqual(tt.d, tt.want) {
t.Errorf("%q. DashboardDefaults() = %v, want %v", tt.name, tt.d, tt.want)
}
}
}
func TestValidDashboardRequest(t *testing.T) {
tests := []struct {
name string
d chronograf.Dashboard
want chronograf.Dashboard
wantErr bool
}{
{
name: "Updates all cell widths/heights",
d: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{
{
W: 0,
H: 0,
Queries: []chronograf.Query{
{
Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00",
},
},
},
{
W: 2,
H: 2,
Queries: []chronograf.Query{
{
Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00",
},
},
},
},
},
want: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{
{
W: 4,
H: 4,
Queries: []chronograf.Query{
{
Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00",
},
},
},
{
W: 2,
H: 2,
Queries: []chronograf.Query{
{
Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00",
},
},
},
},
},
},
{
name: "No queries",
d: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{
{
W: 2,
H: 2,
Queries: []chronograf.Query{},
},
},
},
want: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{
{
W: 2,
H: 2,
Queries: []chronograf.Query{},
},
},
},
wantErr: true,
},
{
name: "Empty Cells",
d: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{},
},
want: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{},
},
wantErr: true,
},
}
for _, tt := range tests {
err := ValidDashboardRequest(&tt.d)
if (err != nil) != tt.wantErr {
t.Errorf("%q. ValidDashboardRequest() error = %v, wantErr %v", tt.name, err, tt.wantErr)
continue
}
if !reflect.DeepEqual(tt.d, tt.want) {
t.Errorf("%q. ValidDashboardRequest() = %v, want %v", tt.name, tt.d, tt.want)
}
}
}
func Test_newDashboardResponse(t *testing.T) {
tests := []struct {
name string
d chronograf.Dashboard
want dashboardResponse
}{
{
name: "Updates all cell widths/heights",
d: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{
{
W: 0,
H: 0,
Queries: []chronograf.Query{
{
Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00",
},
},
},
{
W: 0,
H: 0,
Queries: []chronograf.Query{
{
Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00",
},
},
},
},
},
want: dashboardResponse{
Dashboard: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{
{
W: 4,
H: 4,
Queries: []chronograf.Query{
{
Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00",
},
},
},
{
W: 4,
H: 4,
Queries: []chronograf.Query{
{
Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00",
},
},
},
},
},
Links: dashboardLinks{
Self: "/chronograf/v1/dashboards/0",
},
},
},
}
for _, tt := range tests {
if got := newDashboardResponse(tt.d); !reflect.DeepEqual(got, tt.want) {
t.Errorf("%q. newDashboardResponse() = %v, want %v", tt.name, got, tt.want)
}
}
}

View File

@ -117,7 +117,8 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
router.GET("/chronograf/v1/dashboards/:id", service.DashboardID)
router.DELETE("/chronograf/v1/dashboards/:id", service.RemoveDashboard)
router.PUT("/chronograf/v1/dashboards/:id", service.UpdateDashboard)
router.PUT("/chronograf/v1/dashboards/:id", service.ReplaceDashboard)
router.PATCH("/chronograf/v1/dashboards/:id", service.UpdateDashboard)
/* Authentication */
if opts.UseAuth {

View File

@ -1519,6 +1519,51 @@
}
}
}
},
"patch": {
"tags": [
"layouts"
],
"summary": "Update dashboard information.",
"description": "Update either the dashboard name or the dashboard cells",
"parameters": [
{
"name": "id",
"in": "path",
"type": "integer",
"description": "ID of a dashboard",
"required": true
},
{
"name": "config",
"in": "body",
"description": "dashboard configuration update parameters. Must be either name or cells",
"schema": {
"$ref": "#/definitions/Dashboard"
},
"required": true
}
],
"responses": {
"200": {
"description": "Dashboard has been updated and the new dashboard is returned.",
"schema": {
"$ref": "#/definitions/Dashboard"
}
},
"404": {
"description": "Happens when trying to access a non-existent dashboard.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "A processing or an unexpected error.",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
}
},
@ -2248,12 +2293,16 @@
"w": {
"description": "Width of Cell in the Dashboard",
"type": "integer",
"format": "int32"
"format": "int32",
"minimum": 1,
"default": 4
},
"h": {
"description": "Height of Cell in the Dashboard",
"type": "integer",
"format": "int32"
"format": "int32",
"minimum": 1,
"default": 4
},
"queries": {
"description": "Time-series data queries for Cell.",

View File

@ -182,7 +182,7 @@
'quote-props': [2, 'as-needed', {keywords: true, numbers: false }],
'require-jsdoc': 0,
'semi-spacing': [2, {before: false, after: true}],
'semi': [2, 'always'],
'semi': [0, 'always'],
'sort-vars': 0,
'keyword-spacing': 'error',
'space-before-blocks': [2, 'always'],
@ -194,7 +194,7 @@
'wrap-regex': 0,
'arrow-body-style': 0,
'arrow-spacing': [2, {before: true, after: true}],
'no-confusing-arrow': 2,
'no-confusing-arrow': 0,
'no-class-assign': 2,
'no-const-assign': 2,
'no-dupe-class-members': 2,

View File

@ -0,0 +1,52 @@
import reducer from 'src/dashboards/reducers/ui'
import timeRanges from 'hson!src/shared/data/timeRanges.hson';
import {
loadDashboards,
setDashboard,
setTimeRange,
setEditMode,
} from 'src/dashboards/actions'
const noopAction = () => {
return {type: 'NOOP'}
}
let state = undefined
const timeRange = timeRanges[1];
const d1 = {id: 1, cells: [], name: "d1"}
const d2 = {id: 2, cells: [], name: "d2"}
const dashboards = [d1, d2]
describe('DataExplorer.Reducers.UI', () => {
it('can load the dashboards', () => {
const actual = reducer(state, loadDashboards(dashboards, d1.id))
const expected = {
dashboards,
dashboard: d1,
}
expect(actual.dashboards).to.deep.equal(expected.dashboards)
expect(actual.dashboard).to.deep.equal(expected.dashboard)
})
it('can set a dashboard', () => {
const loadedState = reducer(state, loadDashboards(dashboards, d1.id))
const actual = reducer(loadedState, setDashboard(d2.id))
expect(actual.dashboard).to.deep.equal(d2)
})
it('can set the time range', () => {
const expected = {upper: null, lower: 'now() - 1h'}
const actual = reducer(state, setTimeRange(expected))
expect(actual.timeRange).to.deep.equal(expected)
})
it('can set edit mode', () => {
const isEditMode = true
const actual = reducer(state, setEditMode(isEditMode))
expect(actual.isEditMode).to.equal(isEditMode)
})
})

View File

@ -8,22 +8,29 @@ import {
dismissAllNotifications as dismissAllNotificationsAction,
} from 'src/shared/actions/notifications';
const {
node,
shape,
string,
func,
} = PropTypes
const App = React.createClass({
propTypes: {
children: PropTypes.node.isRequired,
location: PropTypes.shape({
pathname: PropTypes.string,
children: node.isRequired,
location: shape({
pathname: string,
}),
params: PropTypes.shape({
sourceID: PropTypes.string.isRequired,
params: shape({
sourceID: string.isRequired,
}).isRequired,
publishNotification: PropTypes.func.isRequired,
dismissNotification: PropTypes.func.isRequired,
dismissAllNotifications: PropTypes.func.isRequired,
notifications: PropTypes.shape({
success: PropTypes.string,
error: PropTypes.string,
warning: PropTypes.string,
publishNotification: func.isRequired,
dismissNotification: func.isRequired,
dismissAllNotifications: func.isRequired,
notifications: shape({
success: string,
error: string,
warning: string,
}),
},
@ -46,11 +53,15 @@ const App = React.createClass({
},
render() {
const {sourceID} = this.props.params;
const {params: {sourceID}} = this.props;
return (
<div className="chronograf-root">
<SideNavContainer sourceID={sourceID} addFlashMessage={this.handleNotification} currentLocation={this.props.location.pathname} />
<SideNavContainer
sourceID={sourceID}
addFlashMessage={this.handleNotification}
currentLocation={this.props.location.pathname}
/>
{this.renderNotifications()}
{this.props.children && React.cloneElement(this.props.children, {
addFlashMessage: this.handleNotification,

View File

@ -0,0 +1,66 @@
import {
getDashboards as getDashboardsAJAX,
updateDashboard as updateDashboardAJAX,
} from 'src/dashboards/apis'
export function loadDashboards(dashboards, dashboardID) {
return {
type: 'LOAD_DASHBOARDS',
payload: {
dashboards,
dashboardID,
},
}
}
export function setDashboard(dashboardID) {
return {
type: 'SET_DASHBOARD',
payload: {
dashboardID,
},
}
}
export function setTimeRange(timeRange) {
return {
type: 'SET_DASHBOARD_TIME_RANGE',
payload: {
timeRange,
},
}
}
export function setEditMode(isEditMode) {
return {
type: 'SET_EDIT_MODE',
payload: {
isEditMode,
},
}
}
export function getDashboards(dashboardID) {
return (dispatch) => {
getDashboardsAJAX().then(({data: {dashboards}}) => {
dispatch(loadDashboards(dashboards, dashboardID))
});
}
}
export function putDashboard(dashboard) {
return (dispatch) => {
updateDashboardAJAX(dashboard).then(({data}) => {
dispatch(updateDashboard(data))
})
}
}
export function updateDashboard(dashboard) {
return {
type: 'UPDATE_DASHBOARD',
payload: {
dashboard,
},
}
}

View File

@ -6,3 +6,11 @@ export function getDashboards() {
url: `/chronograf/v1/dashboards`,
});
}
export function updateDashboard(dashboard) {
return AJAX({
method: 'PUT',
url: dashboard.links.self,
data: dashboard,
});
}

View File

@ -0,0 +1,72 @@
import React, {PropTypes} from 'react'
import classnames from 'classnames'
import LayoutRenderer from 'shared/components/LayoutRenderer'
import Visualizations from 'src/dashboards/components/VisualizationSelector'
const Dashboard = ({
dashboard,
isEditMode,
inPresentationMode,
onPositionChange,
source,
timeRange,
}) => {
if (dashboard.id === 0) {
return null
}
return (
<div className={classnames({'page-contents': true, 'presentation-mode': inPresentationMode})}>
<div className={classnames('container-fluid full-width dashboard', {'dashboard-edit': isEditMode})}>
{isEditMode ? <Visualizations/> : null}
{Dashboard.renderDashboard(dashboard, timeRange, source, onPositionChange)}
</div>
</div>
)
}
Dashboard.renderDashboard = (dashboard, timeRange, source, onPositionChange) => {
const autoRefreshMs = 15000
const cells = dashboard.cells.map((cell, i) => {
i = `${i}`
const dashboardCell = {...cell, i}
dashboardCell.queries.forEach((q) => {
q.text = q.query;
q.database = source.telegraf;
});
return dashboardCell;
})
return (
<LayoutRenderer
timeRange={timeRange}
cells={cells}
autoRefreshMs={autoRefreshMs}
source={source.links.proxy}
onPositionChange={onPositionChange}
/>
)
}
const {
bool,
func,
shape,
string,
} = PropTypes
Dashboard.propTypes = {
dashboard: shape({}).isRequired,
isEditMode: bool,
inPresentationMode: bool,
onPositionChange: func,
source: shape({
links: shape({
proxy: string,
}).isRequired,
}).isRequired,
timeRange: shape({}).isRequired,
}
export default Dashboard

View File

@ -0,0 +1,77 @@
import React, {PropTypes} from 'react'
import ReactTooltip from 'react-tooltip'
import {Link} from 'react-router';
import TimeRangeDropdown from 'shared/components/TimeRangeDropdown'
const DashboardHeader = ({
children,
buttonText,
dashboard,
headerText,
timeRange,
isHidden,
handleChooseTimeRange,
handleClickPresentationButton,
sourceID,
}) => isHidden ? null : (
<div className="page-header full-width">
<div className="page-header__container">
<div className="page-header__left">
{buttonText &&
<div className="dropdown page-header-dropdown">
<button className="dropdown-toggle" type="button" data-toggle="dropdown">
<span className="button-text">{buttonText}</span>
<span className="caret"></span>
</button>
<ul className="dropdown-menu" aria-labelledby="dropdownMenu1">
{children}
</ul>
</div>
}
{headerText &&
<h1>Kubernetes Dashboard</h1>
}
</div>
<div className="page-header__right">
{sourceID ?
<Link className="btn btn-info btn-sm" to={`/sources/${sourceID}/dashboards/${dashboard && dashboard.id}/edit`} >
<span className="icon pencil" />
&nbsp;Edit
</Link> : null
}
<div className="btn btn-info btn-sm" data-for="graph-tips-tooltip" data-tip="<p><code>Click + Drag</code> Zoom in (X or Y)</p><p><code>Shift + Click</code> Pan Graph Window</p><p><code>Double Click</code> Reset Graph Window</p>">
<span className="icon heart"></span>
Graph Tips
</div>
<ReactTooltip id="graph-tips-tooltip" effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
<TimeRangeDropdown onChooseTimeRange={handleChooseTimeRange} selected={timeRange.inputValue} />
<div className="btn btn-info btn-sm" onClick={handleClickPresentationButton}>
<span className="icon keynote" style={{margin: 0}}></span>
</div>
</div>
</div>
</div>
)
const {
shape,
array,
string,
func,
bool,
} = PropTypes
DashboardHeader.propTypes = {
sourceID: string,
children: array,
buttonText: string,
dashboard: shape({}),
headerText: string,
timeRange: shape({}).isRequired,
isHidden: bool.isRequired,
handleChooseTimeRange: func.isRequired,
handleClickPresentationButton: func.isRequired,
}
export default DashboardHeader

View File

@ -0,0 +1,36 @@
import React, {PropTypes} from 'react'
const DashboardEditHeader = ({
dashboard,
onSave,
}) => (
<div className="page-header full-width">
<div className="page-header__container">
<div className="page-header__left">
<input
className="chronograf-header__editing"
autoFocus={true}
defaultValue={dashboard && dashboard.name}
placeholder="Dashboard name"
/>
</div>
<div className="page-header__right">
<div className="btn btn-sm btn-success" onClick={onSave}>
Save
</div>
</div>
</div>
</div>
)
const {
shape,
func,
} = PropTypes
DashboardEditHeader.propTypes = {
dashboard: shape({}),
onSave: func.isRequired,
}
export default DashboardEditHeader

View File

@ -0,0 +1,24 @@
import React from 'react'
const VisualizationSelector = () => (
<div className="" style={{
display: 'flex',
width: '100%',
background: '#676978',
padding: '10px',
borderRadius: '3px',
marginBottom: '10px',
}}>
<div className="">
VISUALIZATIONS
<div className="btn btn-info" style={{margin: "0 5px 0 5px"}}>
Line Graph
</div>
<div className="btn btn-info" style={{margin: "0 5px 0 5px"}}>
SingleStat
</div>
</div>
</div>
)
export default VisualizationSelector

View File

@ -0,0 +1,12 @@
export const EMPTY_DASHBOARD = {
id: 0,
name: '',
cells: [
{
x: 0,
y: 0,
queries: [],
name: 'Loading...',
},
],
}

View File

@ -1,130 +1,169 @@
import React, {PropTypes} from 'react';
import ReactTooltip from 'react-tooltip';
import {Link} from 'react-router';
import _ from 'lodash';
import React, {PropTypes} from 'react'
import {Link} from 'react-router'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import LayoutRenderer from 'shared/components/LayoutRenderer';
import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown';
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
import Header from 'src/dashboards/components/DashboardHeader'
import EditHeader from 'src/dashboards/components/DashboardHeaderEdit'
import Dashboard from 'src/dashboards/components/Dashboard'
import timeRanges from 'hson!../../shared/data/timeRanges.hson'
import {getDashboards} from '../apis';
import {getSource} from 'shared/apis';
import * as dashboardActionCreators from 'src/dashboards/actions'
import {presentationButtonDispatcher} from 'shared/dispatchers'
const {
arrayOf,
bool,
func,
number,
shape,
string,
} = PropTypes
const DashboardPage = React.createClass({
propTypes: {
params: PropTypes.shape({
sourceID: PropTypes.string.isRequired,
dashboardID: PropTypes.string.isRequired,
source: PropTypes.shape({
links: PropTypes.shape({
proxy: PropTypes.string,
self: PropTypes.string,
}),
}),
params: shape({
sourceID: string.isRequired,
dashboardID: string.isRequired,
}).isRequired,
},
getInitialState() {
const fifteenMinutesIndex = 1;
return {
dashboards: [],
timeRange: timeRanges[fifteenMinutesIndex],
};
location: shape({
pathname: string.isRequired,
}).isRequired,
dashboardActions: shape({
putDashboard: func.isRequired,
getDashboards: func.isRequired,
setDashboard: func.isRequired,
setTimeRange: func.isRequired,
setEditMode: func.isRequired,
}).isRequired,
dashboards: arrayOf(shape({
id: number.isRequired,
cells: arrayOf(shape({})).isRequired,
})).isRequired,
dashboard: shape({
id: number.isRequired,
cells: arrayOf(shape({})).isRequired,
}).isRequired,
timeRange: shape({}).isRequired,
inPresentationMode: bool.isRequired,
isEditMode: bool.isRequired,
handleClickPresentationButton: func,
},
componentDidMount() {
getDashboards().then((resp) => {
getSource(this.props.params.sourceID).then(({data: source}) => {
this.setState({
dashboards: resp.data.dashboards,
source,
});
});
});
const {
params: {dashboardID},
dashboardActions: {getDashboards},
} = this.props;
getDashboards(dashboardID)
},
currentDashboard(dashboards, dashboardID) {
return _.find(dashboards, (d) => d.id.toString() === dashboardID);
},
componentWillReceiveProps(nextProps) {
const {location: {pathname}} = this.props
const {
location: {pathname: nextPathname},
params: {dashboardID: nextID},
dashboardActions: {setDashboard, setEditMode},
} = nextProps
renderDashboard(dashboard) {
const autoRefreshMs = 15000;
const {timeRange} = this.state;
const {source} = this.state;
if (nextPathname.pathname === pathname) {
return
}
const cellWidth = 4;
const cellHeight = 4;
const cells = dashboard.cells.map((cell, i) => {
const dashboardCell = Object.assign(cell, {
w: cellWidth,
h: cellHeight,
queries: cell.queries,
i: i.toString(),
});
dashboardCell.queries.forEach((q) => {
q.text = q.query;
q.database = source.telegraf;
});
return dashboardCell;
});
return (
<LayoutRenderer
timeRange={timeRange}
cells={cells}
autoRefreshMs={autoRefreshMs}
source={source.links.proxy}
/>
);
setDashboard(nextID)
setEditMode(nextPathname.includes('/edit'))
},
handleChooseTimeRange({lower}) {
const timeRange = timeRanges.find((range) => range.queryValue === lower);
this.setState({timeRange});
this.props.dashboardActions.setTimeRange(timeRange)
},
handleUpdatePosition(cells) {
this.props.dashboardActions.putDashboard({...this.props.dashboard, cells})
},
render() {
const {dashboards, timeRange} = this.state;
const dashboard = this.currentDashboard(dashboards, this.props.params.dashboardID);
const {
dashboards,
dashboard,
params: {sourceID},
inPresentationMode,
isEditMode,
handleClickPresentationButton,
source,
timeRange,
} = this.props
return (
<div className="page">
<div className="page-header full-width">
<div className="page-header__container">
<div className="page-header__left">
<div className="dropdown page-header-dropdown">
<button className="dropdown-toggle" type="button" data-toggle="dropdown">
<span className="button-text">{dashboard ? dashboard.name : ''}</span>
<span className="caret"></span>
</button>
<ul className="dropdown-menu" aria-labelledby="dropdownMenu1">
{(dashboards).map((d, i) => {
return (
<li key={i}>
<Link to={`/sources/${this.props.params.sourceID}/dashboards/${d.id}`} className="role-option">
{d.name}
</Link>
</li>
);
})}
</ul>
</div>
</div>
<div className="page-header__right">
<div className="btn btn-info btn-sm" data-for="graph-tips-tooltip" data-tip="<p><code>Click + Drag</code> Zoom in (X or Y)</p><p><code>Shift + Click</code> Pan Graph Window</p><p><code>Double Click</code> Reset Graph Window</p>">
<span className="icon heart"></span>
Graph Tips
</div>
<ReactTooltip id="graph-tips-tooltip" effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
<TimeRangeDropdown onChooseTimeRange={this.handleChooseTimeRange} selected={timeRange.inputValue} />
</div>
</div>
</div>
<div className="page-contents">
<div className="container-fluid full-width">
{ dashboard ? this.renderDashboard(dashboard) : '' }
</div>
</div>
{
isEditMode ?
<EditHeader dashboard={dashboard} onSave={() => {}} /> :
<Header
buttonText={dashboard ? dashboard.name : ''}
timeRange={timeRange}
handleChooseTimeRange={this.handleChooseTimeRange}
isHidden={inPresentationMode}
handleClickPresentationButton={handleClickPresentationButton}
dashboard={dashboard}
sourceID={sourceID}
>
{(dashboards).map((d, i) => {
return (
<li key={i}>
<Link to={`/sources/${sourceID}/dashboards/${d.id}`} className="role-option">
{d.name}
</Link>
</li>
);
})}
</Header>
}
<Dashboard
dashboard={dashboard}
isEditMode={isEditMode}
inPresentationMode={inPresentationMode}
source={source}
timeRange={timeRange}
onPositionChange={this.handleUpdatePosition}
/>
</div>
);
},
});
export default DashboardPage;
const mapStateToProps = (state) => {
const {
appUI,
dashboardUI: {
dashboards,
dashboard,
timeRange,
isEditMode,
},
} = state
return {
inPresentationMode: appUI.presentationMode,
dashboards,
dashboard,
timeRange,
isEditMode,
}
}
const mapDispatchToProps = (dispatch) => ({
handleClickPresentationButton: presentationButtonDispatcher(dispatch),
dashboardActions: bindActionCreators(dashboardActionCreators, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(DashboardPage);

View File

@ -34,13 +34,14 @@ const DashboardsPage = React.createClass({
},
render() {
const dashboardLink = `/sources/${this.props.source.id}`;
let tableHeader;
if (this.state.waiting) {
tableHeader = "Loading Dashboards...";
} else if (this.state.dashboards.length === 0) {
tableHeader = "No Dashboards";
tableHeader = "1 Dashboard";
} else {
tableHeader = `${this.state.dashboards.length} Dashboards`;
tableHeader = `${this.state.dashboards.length + 1} Dashboards`;
}
return (
@ -75,7 +76,7 @@ const DashboardsPage = React.createClass({
return (
<tr key={dashboard.id}>
<td className="monotype">
<Link to={`/sources/${this.props.source.id}/dashboards/${dashboard.id}`}>
<Link to={`${dashboardLink}/dashboards/${dashboard.id}`}>
{dashboard.name}
</Link>
</td>
@ -83,6 +84,13 @@ const DashboardsPage = React.createClass({
);
})
}
<tr>
<td className="monotype">
<Link to={`${dashboardLink}/kubernetes`}>
{'Kubernetes'}
</Link>
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -0,0 +1,56 @@
import _ from 'lodash';
import {EMPTY_DASHBOARD} from 'src/dashboards/constants'
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
const initialState = {
dashboards: [],
dashboard: EMPTY_DASHBOARD,
timeRange: timeRanges[1],
isEditMode: false,
};
export default function ui(state = initialState, action) {
switch (action.type) {
case 'LOAD_DASHBOARDS': {
const {dashboards, dashboardID} = action.payload;
const newState = {
dashboards,
dashboard: _.find(dashboards, (d) => d.id === +dashboardID),
};
return {...state, ...newState};
}
case 'SET_DASHBOARD': {
const {dashboardID} = action.payload
const newState = {
dashboard: _.find(state.dashboards, (d) => d.id === +dashboardID),
};
return {...state, ...newState}
}
case 'SET_DASHBOARD_TIME_RANGE': {
const {timeRange} = action.payload
return {...state, timeRange};
}
case 'SET_EDIT_MODE': {
const {isEditMode} = action.payload
return {...state, isEditMode}
}
case 'UPDATE_DASHBOARD': {
const {dashboard} = action.payload
const newState = {
dashboard,
dashboards: state.dashboards.map((d) => d.id === dashboard.id ? dashboard : d),
}
return {...state, ...newState}
}
}
return state;
}

View File

@ -73,7 +73,7 @@ const Visualization = React.createClass({
</ul>
</div>
</div>
<div className={classNames("", {"graph-container": isGraphInView, "table-container": !isGraphInView})}>
<div className={classNames({"graph-container": isGraphInView, "table-container": !isGraphInView})}>
{isGraphInView ? (
<RefreshingLineGraph
queries={queries}

View File

@ -1,31 +1,42 @@
import React, {PropTypes} from 'react';
import ReactTooltip from 'react-tooltip';
import {Link} from 'react-router';
import _ from 'lodash';
import React, {PropTypes} from 'react'
import {Link} from 'react-router'
import {connect} from 'react-redux'
import _ from 'lodash'
import classnames from 'classnames';
import LayoutRenderer from 'shared/components/LayoutRenderer';
import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown';
import DashboardHeader from 'src/dashboards/components/DashboardHeader';
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
import {getMappings, getAppsForHosts, getMeasurementsForHost, getAllHosts} from 'src/hosts/apis';
import {fetchLayouts} from 'shared/apis';
import {presentationButtonDispatcher} from 'shared/dispatchers'
const {
shape,
string,
bool,
func,
} = PropTypes
export const HostPage = React.createClass({
propTypes: {
source: PropTypes.shape({
links: PropTypes.shape({
proxy: PropTypes.string.isRequired,
source: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
telegraf: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
telegraf: string.isRequired,
id: string.isRequired,
}),
params: PropTypes.shape({
hostID: PropTypes.string.isRequired,
params: shape({
hostID: string.isRequired,
}).isRequired,
location: PropTypes.shape({
query: PropTypes.shape({
app: PropTypes.string,
location: shape({
query: shape({
app: string,
}),
}),
inPresentationMode: bool,
handleClickPresentationButton: func,
},
getInitialState() {
@ -134,45 +145,34 @@ export const HostPage = React.createClass({
},
render() {
const hostID = this.props.params.hostID;
const {layouts, timeRange, hosts} = this.state;
const appParam = this.props.location.query.app ? `?app=${this.props.location.query.app}` : '';
const {params: {hostID}, location: {query: {app}}, source: {id}, inPresentationMode, handleClickPresentationButton} = this.props
const {layouts, timeRange, hosts} = this.state
const appParam = app ? `?app=${app}` : ''
return (
<div className="page">
<div className="page-header full-width">
<div className="page-header__container">
<div className="page-header__left">
<div className="dropdown page-header-dropdown">
<button className="dropdown-toggle" type="button" data-toggle="dropdown">
<span className="button-text">{hostID}</span>
<span className="caret"></span>
</button>
<ul className="dropdown-menu" aria-labelledby="dropdownMenu1">
{Object.keys(hosts).map((host, i) => {
return (
<li key={i}>
<Link to={`/sources/${this.props.source.id}/hosts/${host + appParam}`} className="role-option">
{host}
</Link>
</li>
);
})}
</ul>
</div>
</div>
<div className="page-header__right">
<div className="btn btn-info btn-sm" data-for="graph-tips-tooltip" data-tip="<p><code>Click + Drag</code> Zoom in (X or Y)</p><p><code>Shift + Click</code> Pan Graph Window</p><p><code>Double Click</code> Reset Graph Window</p>">
<span className="icon heart"></span>
Graph Tips
</div>
<ReactTooltip id="graph-tips-tooltip" effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
<TimeRangeDropdown onChooseTimeRange={this.handleChooseTimeRange} selected={timeRange.inputValue} />
</div>
</div>
</div>
<div className="page-contents">
<div className="container-fluid full-width">
<DashboardHeader
buttonText={hostID}
timeRange={timeRange}
isHidden={inPresentationMode}
handleChooseTimeRange={this.handleChooseTimeRange}
handleClickPresentationButton={handleClickPresentationButton}
>
{Object.keys(hosts).map((host, i) => {
return (
<li key={i}>
<Link to={`/sources/${id}/hosts/${host + appParam}`} className="role-option">
{host}
</Link>
</li>
);
})}
</DashboardHeader>
<div className={classnames({
'page-contents': true,
'presentation-mode': inPresentationMode,
})}>
<div className="container-fluid full-width dashboard">
{ (layouts.length > 0) ? this.renderLayouts(layouts) : '' }
</div>
</div>
@ -181,4 +181,12 @@ export const HostPage = React.createClass({
},
});
export default HostPage;
const mapStateToProps = (state) => ({
inPresentationMode: state.appUI.presentationMode,
})
const mapDispatchToProps = (dispatch) => ({
handleClickPresentationButton: presentationButtonDispatcher(dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(HostPage)

View File

@ -18,6 +18,7 @@ import NotFound from 'src/shared/components/NotFound';
import configureStore from 'src/store/configureStore';
import {getMe, getSources} from 'shared/apis';
import {receiveMe} from 'shared/actions/me';
import {disablePresentationMode} from 'shared/actions/ui';
import {loadLocalStorage} from './localStorage';
import 'src/style/chronograf.scss';
@ -38,6 +39,12 @@ if (basepath) {
});
}
window.addEventListener('keyup', (event) => {
if (event.key === 'Escape') {
store.dispatch(disablePresentationMode())
}
})
const Root = React.createClass({
getInitialState() {
return {
@ -116,6 +123,7 @@ const Root = React.createClass({
<Route path="alerts" component={AlertsApp} />
<Route path="dashboards" component={DashboardsPage} />
<Route path="dashboards/:dashboardID" component={DashboardPage} />
<Route path="dashboards/:dashboardID/edit" component={DashboardPage} />
<Route path="alert-rules" component={KapacitorRulesPage} />
<Route path="alert-rules/:ruleID" component={KapacitorRulePage} />
<Route path="alert-rules/new" component={KapacitorRulePage} />

View File

@ -1,18 +1,29 @@
import React, {PropTypes} from 'react';
import classnames from 'classnames'
import LayoutRenderer from 'shared/components/LayoutRenderer';
import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown';
import ReactTooltip from 'react-tooltip';
import DashboardHeader from 'src/dashboards/components/DashboardHeader';
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
export const KubernetesPage = React.createClass({
const {
shape,
string,
arrayOf,
bool,
func,
} = PropTypes
export const KubernetesDashboard = React.createClass({
propTypes: {
source: PropTypes.shape({
links: PropTypes.shape({
proxy: PropTypes.string.isRequired,
source: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
telegraf: PropTypes.string.isRequired,
telegraf: string.isRequired,
}),
layouts: PropTypes.arrayOf(PropTypes.shape().isRequired).isRequired,
layouts: arrayOf(shape().isRequired).isRequired,
inPresentationMode: bool.isRequired,
handleClickPresentationButton: func,
},
getInitialState() {
@ -57,7 +68,7 @@ export const KubernetesPage = React.createClass({
},
render() {
const {layouts} = this.props;
const {layouts, inPresentationMode, handleClickPresentationButton} = this.props;
const {timeRange} = this.state;
const emptyState = (
<div className="generic-empty-state">
@ -68,23 +79,18 @@ export const KubernetesPage = React.createClass({
return (
<div className="page">
<div className="page-header full-width">
<div className="page-header__container">
<div className="page-header__left">
<h1>Kubernetes Dashboard</h1>
</div>
<div className="page-header__right">
<div className="btn btn-info btn-sm" data-for="graph-tips-tooltip" data-tip="<p><code>Click + Drag</code> Zoom in (X or Y)</p><p><code>Shift + Click</code> Pan Graph Window</p><p><code>Double Click</code> Reset Graph Window</p>">
<span className="icon heart"></span>
Graph Tips
</div>
<ReactTooltip id="graph-tips-tooltip" effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
<TimeRangeDropdown onChooseTimeRange={this.handleChooseTimeRange} selected={timeRange.inputValue} />
</div>
</div>
</div>
<div className="page-contents">
<div className="container-fluid full-width">
<DashboardHeader
headerText="Kubernetes Dashboard"
timeRange={timeRange}
handleChooseTimeRange={this.handleChooseTimeRange}
isHidden={inPresentationMode}
handleClickPresentationButton={handleClickPresentationButton}
/>
<div className={classnames({
'page-contents': true,
'presentation-mode': inPresentationMode,
})}>
<div className="container-fluid full-width dashboard">
{layouts.length ? this.renderLayouts(layouts) : emptyState}
</div>
</div>
@ -92,4 +98,5 @@ export const KubernetesPage = React.createClass({
);
},
});
export default KubernetesPage;
export default KubernetesDashboard;

View File

@ -1,14 +1,26 @@
import React, {PropTypes} from 'react';
import {connect} from 'react-redux'
import {fetchLayouts} from 'shared/apis';
import KubernetesDashboard from 'src/kubernetes/components/KubernetesDashboard';
import {presentationButtonDispatcher} from 'shared/dispatchers'
const {
shape,
string,
bool,
func,
} = PropTypes
export const KubernetesPage = React.createClass({
propTypes: {
source: PropTypes.shape({
links: PropTypes.shape({
proxy: PropTypes.string.isRequired,
source: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}),
inPresentationMode: bool.isRequired,
handleClickPresentationButton: func,
},
getInitialState() {
@ -25,10 +37,26 @@ export const KubernetesPage = React.createClass({
},
render() {
const {layouts} = this.state
const {source, inPresentationMode, handleClickPresentationButton} = this.props
return (
<KubernetesDashboard layouts={this.state.layouts} source={this.props.source} />
<KubernetesDashboard
layouts={layouts}
source={source}
inPresentationMode={inPresentationMode}
handleClickPresentationButton={handleClickPresentationButton}
/>
);
},
});
export default KubernetesPage;
const mapStateToProps = (state) => ({
inPresentationMode: state.appUI.presentationMode,
})
const mapDispatchToProps = (dispatch) => ({
handleClickPresentationButton: presentationButtonDispatcher(dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(KubernetesPage);

View File

@ -17,6 +17,12 @@ export function dismissNotification(type) {
};
}
export function delayDismissNotification(type, wait) {
return (dispatch) => {
setTimeout(() => dispatch(dismissNotification(type)), wait)
}
}
export function dismissAllNotifications() {
return {
type: 'ALL_NOTIFICATIONS_DISMISSED',

View File

@ -0,0 +1,19 @@
import {PRESENTATION_MODE_ANIMATION_DELAY} from '../constants'
export function enablePresentationMode() {
return {
type: 'ENABLE_PRESENTATION_MODE',
}
}
export function disablePresentationMode() {
return {
type: 'DISABLE_PRESENTATION_MODE',
}
}
export function delayEnablePresentationMode() {
return (dispatch) => {
setTimeout(() => dispatch(enablePresentationMode()), PRESENTATION_MODE_ANIMATION_DELAY)
}
}

View File

@ -108,6 +108,7 @@ export default React.createClass({
const legendWidth = legendRect.width;
const legendMaxLeft = graphWidth - (legendWidth / 2);
const trueGraphX = (e.pageX - graphRect.left);
const legendTop = graphRect.height + 0
let legendLeft = trueGraphX;
// Enforcing max & min legend offsets
if (trueGraphX < (legendWidth / 2)) {
@ -117,6 +118,7 @@ export default React.createClass({
}
legendContainerNode.style.left = `${legendLeft}px`;
legendContainerNode.style.top = `${legendTop}px`;
setMarker(points);
},
unhighlightCallback() {

View File

@ -4,50 +4,52 @@ import LineGraph from 'shared/components/LineGraph';
import SingleStat from 'shared/components/SingleStat';
import ReactGridLayout, {WidthProvider} from 'react-grid-layout';
const GridLayout = WidthProvider(ReactGridLayout);
import _ from 'lodash';
const RefreshingLineGraph = AutoRefresh(LineGraph);
const RefreshingSingleStat = AutoRefresh(SingleStat);
const {
arrayOf,
func,
number,
shape,
string,
} = PropTypes;
export const LayoutRenderer = React.createClass({
propTypes: {
timeRange: PropTypes.shape({
defaultGroupBy: PropTypes.string.isRequired,
queryValue: PropTypes.string.isRequired,
timeRange: shape({
defaultGroupBy: string.isRequired,
queryValue: string.isRequired,
}).isRequired,
cells: PropTypes.arrayOf(
PropTypes.shape({
queries: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string,
range: PropTypes.shape({
upper: PropTypes.number,
lower: PropTypes.number,
cells: arrayOf(
shape({
queries: arrayOf(
shape({
label: string,
range: shape({
upper: number,
lower: number,
}),
rp: PropTypes.string,
text: PropTypes.string.isRequired,
database: PropTypes.string.isRequired,
groupbys: PropTypes.arrayOf(PropTypes.string),
wheres: PropTypes.arrayOf(PropTypes.string),
rp: string,
text: string.isRequired,
database: string.isRequired,
groupbys: arrayOf(string),
wheres: arrayOf(string),
}).isRequired
).isRequired,
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
w: PropTypes.number.isRequired,
h: PropTypes.number.isRequired,
i: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
x: number.isRequired,
y: number.isRequired,
w: number.isRequired,
h: number.isRequired,
i: string.isRequired,
name: string.isRequired,
}).isRequired
),
autoRefreshMs: PropTypes.number.isRequired,
host: PropTypes.string,
source: PropTypes.string,
},
getInitialState() {
return ({
layout: _.without(this.props.cells, ['queries']),
});
autoRefreshMs: number.isRequired,
host: string,
source: string,
onPositionChange: func,
},
buildQuery(q) {
@ -96,8 +98,8 @@ export const LayoutRenderer = React.createClass({
if (cell.type === 'single-stat') {
return (
<div key={cell.i}>
<h2 className="hosts-graph-heading">{cell.name}</h2>
<div className="hosts-graph graph-container">
<h2 className="dash-graph--heading">{cell.name || `Graph`}</h2>
<div className="dash-graph--container">
<RefreshingSingleStat queries={[qs[0]]} autoRefresh={autoRefreshMs} />
</div>
</div>
@ -111,8 +113,8 @@ export const LayoutRenderer = React.createClass({
return (
<div key={cell.i}>
<h2 className="hosts-graph-heading">{cell.name}</h2>
<div className="hosts-graph graph-container">
<h2 className="dash-graph--heading">{cell.name || `Graph`}</h2>
<div className="dash-graph--container">
<RefreshingLineGraph
queries={qs}
autoRefresh={autoRefreshMs}
@ -125,14 +127,52 @@ export const LayoutRenderer = React.createClass({
});
},
handleLayoutChange(layout) {
this.triggerWindowResize()
if (!this.props.onPositionChange) {
return
}
const newCells = this.props.cells.map((cell) => {
const l = layout.find((ly) => ly.i === cell.i)
const newLayout = {x: l.x, y: l.y, h: l.h, w: l.w}
return {...cell, ...newLayout}
})
this.props.onPositionChange(newCells)
},
render() {
const layoutMargin = 4;
const layoutMargin = 4
const isDashboard = !!this.props.onPositionChange
return (
<GridLayout layout={this.state.layout} isDraggable={false} isResizable={false} cols={12} rowHeight={83.5} margin={[layoutMargin, layoutMargin]} containerPadding={[0, 0]} useCSSTransforms={false} >
<GridLayout
layout={this.props.cells}
cols={12}
rowHeight={83.5}
margin={[layoutMargin, layoutMargin]}
containerPadding={[0, 0]}
useCSSTransforms={false}
onResize={this.triggerWindowResize}
onLayoutChange={this.handleLayoutChange}
draggableHandle={'.dash-graph--heading'}
isDraggable={isDashboard}
isResizable={isDashboard}
>
{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);
},
});
export default LayoutRenderer;

View File

@ -103,7 +103,7 @@ export default React.createClass({
<div className={classNames({"graph--hasYLabel": !!(options.ylabel || options.y2label)})}>
{isRefreshing ? this.renderSpinner() : null}
<Dygraph
containerStyle={{width: '100%', height: '300px'}}
containerStyle={{width: '100%', height: '100%'}}
overrideLineColors={overrideLineColors}
isGraphFilled={isGraphFilled}
timeSeries={timeSeries}

View File

@ -466,4 +466,7 @@ export const DEFAULT_LINE_COLORS = [
export const STROKE_WIDTH = {
heavy: 3.5,
light: 1.5,
};
};
export const PRESENTATION_MODE_ANIMATION_DELAY = 250 // In milliseconds.
export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds.

View File

@ -0,0 +1,9 @@
import {delayEnablePresentationMode} from 'shared/actions/ui'
import {publishNotification, delayDismissNotification} from 'shared/actions/notifications'
import {PRESENTATION_MODE_NOTIFICATION_DELAY} from 'shared/constants'
export const presentationButtonDispatcher = (dispatch) => () => {
dispatch(delayEnablePresentationMode())
dispatch(publishNotification('success', 'Press ESC to disable presentation mode.'))
dispatch(delayDismissNotification('success', PRESENTATION_MODE_NOTIFICATION_DELAY))
}

View File

@ -1,8 +1,10 @@
import appUI from './ui';
import me from './me';
import notifications from './notifications';
import sources from './sources';
export {
appUI,
me,
notifications,
sources,

View File

@ -0,0 +1,23 @@
const initialState = {
presentationMode: false,
};
export default function ui(state = initialState, action) {
switch (action.type) {
case 'ENABLE_PRESENTATION_MODE': {
return {
...state,
presentationMode: true,
}
}
case 'DISABLE_PRESENTATION_MODE': {
return {
...state,
presentationMode: false,
}
}
}
return state
}

View File

@ -1,7 +1,11 @@
import React, {PropTypes} from 'react';
import {NavBar, NavBlock, NavHeader, NavListItem} from 'src/side_nav/components/NavItems';
const {string, shape} = PropTypes;
const {
string,
shape,
bool,
} = PropTypes;
const SideNav = React.createClass({
propTypes: {
location: string.isRequired,
@ -9,36 +13,36 @@ const SideNav = React.createClass({
me: shape({
email: string,
}),
isHidden: bool.isRequired,
},
render() {
const {me, location, sourceID} = this.props;
const {me, location, sourceID, isHidden} = this.props;
const sourcePrefix = `/sources/${sourceID}`;
const dataExplorerLink = `${sourcePrefix}/chronograf/data-explorer`;
const loggedIn = !!(me && me.email);
return (
return isHidden ? null : (
<NavBar location={location}>
<div className="sidebar__logo">
<a href="/"><span className="icon cubo-uniform"></span></a>
</div>
<NavBlock icon="cpu" link={`${sourcePrefix}/hosts`}>
<NavHeader link={`${sourcePrefix}/hosts`} title="Infrastructure" />
<NavListItem link={`${sourcePrefix}/hosts`}>Host List</NavListItem>
<NavListItem link={`${sourcePrefix}/kubernetes`}>Kubernetes Dashboard</NavListItem>
<NavBlock icon="cubo-node" link={`${sourcePrefix}/hosts`}>
<NavHeader link={`${sourcePrefix}/hosts`} title="Host List" />
</NavBlock>
<NavBlock icon="graphline" link={dataExplorerLink}>
<NavHeader link={dataExplorerLink} title={'Data'} />
<NavListItem link={dataExplorerLink}>Explorer</NavListItem>
<NavListItem link={`${sourcePrefix}/dashboards`}>Dashboards</NavListItem>
<NavHeader link={dataExplorerLink} title="Data Explorer" />
</NavBlock>
<NavBlock matcher="alerts" icon="pulse-b" link={`${sourcePrefix}/alerts`}>
<NavBlock icon="dash-h" link={`${sourcePrefix}/dashboards`}>
<NavHeader link={`${sourcePrefix}/dashboards`} title={'Dashboards'} />
</NavBlock>
<NavBlock matcher="alerts" icon="alert-triangle" link={`${sourcePrefix}/alerts`}>
<NavHeader link={`${sourcePrefix}/alerts`} title="Alerting" />
<NavListItem link={`${sourcePrefix}/alerts`}>Alert History</NavListItem>
<NavListItem link={`${sourcePrefix}/alert-rules`}>Kapacitor Rules</NavListItem>
</NavBlock>
<NavBlock icon="access-key" link={`${sourcePrefix}/manage-sources`}>
<NavBlock icon="cog-thick" link={`${sourcePrefix}/manage-sources`}>
<NavHeader link={`${sourcePrefix}/manage-sources`} title="Configuration" />
<NavListItem link={`${sourcePrefix}/manage-sources`}>InfluxDB</NavListItem>
<NavListItem link={`${sourcePrefix}/kapacitor-config`}>Kapacitor</NavListItem>

View File

@ -2,7 +2,13 @@ import React, {PropTypes} from 'react';
import {connect} from 'react-redux';
import SideNav from '../components/SideNav';
const {func, string, shape} = PropTypes;
const {
func,
string,
shape,
bool,
} = PropTypes
const SideNavApp = React.createClass({
propTypes: {
currentLocation: string.isRequired,
@ -11,25 +17,27 @@ const SideNavApp = React.createClass({
me: shape({
email: string,
}),
inPresentationMode: bool.isRequired,
},
render() {
const {me, currentLocation, sourceID} = this.props;
const {me, currentLocation, sourceID, inPresentationMode} = this.props;
return (
<SideNav
sourceID={sourceID}
location={currentLocation}
me={me}
isHidden={inPresentationMode}
/>
);
},
});
function mapStateToProps(state) {
return {
me: state.me,
inPresentationMode: state.appUI.presentationMode,
};
}

View File

@ -5,12 +5,14 @@ import makeQueryExecuter from 'src/shared/middleware/queryExecuter';
import * as dataExplorerReducers from 'src/data_explorer/reducers';
import * as sharedReducers from 'src/shared/reducers';
import rulesReducer from 'src/kapacitor/reducers/rules';
import dashboardUI from 'src/dashboards/reducers/ui';
import persistStateEnhancer from './persistStateEnhancer';
const rootReducer = combineReducers({
...sharedReducers,
...dataExplorerReducers,
rules: rulesReducer,
dashboardUI,
});
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

View File

@ -45,6 +45,7 @@
@import 'pages/hosts';
@import 'pages/kapacitor';
@import 'pages/data-explorer';
@import 'pages/dashboards';
// TODO
@import 'unsorted';

View File

@ -15,9 +15,8 @@
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='fade-out($g20-white, 0.71)', endColorstr='fade-out($g20-white, 0.71)',GradientType=0 );
}
.container--dygraph-legend {
top: 300px !important;
transform: translate(-50%,-6px);
background-color: $g1-raven;
transform: translateX(-50%);
background-color: $g0-obsidian;
display: block;
position: absolute;
padding: 11px;
@ -119,9 +118,9 @@
.graph--hasYLabel {
.dygraph-axis-label-y {
padding: 0 1px 0 12px !important;
padding: 0 1px 0 10px !important;
}
.dygraph-axis-label-y2 {
padding: 0 12px 0 1px !important;
padding: 0 10px 0 1px !important;
}
}

View File

@ -35,6 +35,15 @@
&--purple-scrollbar {
@include custom-scrollbar($g2-kevlar,$c-comet);
}
&.presentation-mode {
top: 0;
height: 100%;
.dashboard {
padding: 12px;
}
}
}
.container-fluid {
padding: ($chronograf-page-header-height / 2) $page-wrapper-padding ($chronograf-page-header-height / 2) $page-wrapper-padding;
@ -457,4 +466,4 @@ table .monotype {
margin-bottom: 75px;
}
}
}
}

View File

@ -13,9 +13,31 @@
background: linear-gradient(to right, $startColor 0%,$endColor 100%);
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 );
}
@mixin gradient-diag-up($startColor, $endColor) {
background: $startColor;
background: -moz-linear-gradient(45deg, $startColor 0%, $endColor 100%);
background: -webkit-linear-gradient(45deg, $startColor 0%,$endColor 100%);
background: linear-gradient(45deg, $startColor 0%,$endColor 100%);
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 );
}
@mixin gradient-diag-down($startColor, $endColor) {
background: $startColor;
background: -moz-linear-gradient(135deg, $startColor 0%, $endColor 100%);
background: -webkit-linear-gradient(135deg, $startColor 0%,$endColor 100%);
background: linear-gradient(135deg, $startColor 0%,$endColor 100%);
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 );
}
@mixin gradient-r($startColor, $endColor) {
background: $startColor;
background: -moz-radial-gradient(center, ellipse cover, $startColor 0%, $endColor 100%);
background: -webkit-radial-gradient(center, ellipse cover, $startColor 0%,$endColor 100%);
background: radial-gradient(ellipse at center, $startColor 0%,$endColor 100%);
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 );
}
// Custom Scrollbars (Chrome Only)
$scrollbar-width: 16px;
$scrollbar-offset: 3px;
@mixin custom-scrollbar($trackColor, $handleColor) {
&::-webkit-scrollbar {
width: $scrollbar-width;
@ -30,12 +52,12 @@ $scrollbar-width: 16px;
}
&-track-piece {
background-color: $trackColor;
border: 3px solid $trackColor;
border: $scrollbar-offset solid $trackColor;
border-radius: ($scrollbar-width / 2);
}
&-thumb {
background-color: $handleColor;
border: 3px solid $trackColor;
border: $scrollbar-offset solid $trackColor;
border-radius: ($scrollbar-width / 2);
}
&-corner {
@ -45,4 +67,4 @@ $scrollbar-width: 16px;
&::-webkit-resizer {
background-color: $trackColor;
}
}
}

View File

@ -0,0 +1,229 @@
/*
Variables
------------------------------------------------------
*/
$dash-graph-heading: 30px;
/*
Animations
------------------------------------------------------
*/
@keyframes refreshingSpinnerA {
0% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; }
33% { transform: translate(-50%,-50%) scale(1,1); }
66% { transform: translate(-50%,-50%) scale(1,1); }
100% { transform: translate(-50%,-50%) scale(1,1); }
}
@keyframes refreshingSpinnerB {
0% { transform: translate(-50%,-50%) scale(1,1); }
33% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; }
66% { transform: translate(-50%,-50%) scale(1,1); }
100% { transform: translate(-50%,-50%) scale(1,1); }
}
@keyframes refreshingSpinnerC {
0% { transform: translate(-50%,-50%) scale(1,1); }
33% { transform: translate(-50%,-50%) scale(1,1); }
66% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; }
100% { transform: translate(-50%,-50%) scale(1,1); }
}
/*
Default Dashboard Mode
------------------------------------------------------
*/
.dashboard {
.react-grid-item {
background-color: $g3-castle;
border-radius: $radius;
border: 2px solid $g3-castle;
transition-property: left, top, border-color, background-color;
}
.graph-empty {
background-color: transparent;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
}
.dash-graph--container {
user-select: none !important;
-o-user-select: none !important;
-moz-user-select: none !important;
-webkit-user-select: none !important;
background-color: transparent;
position: absolute;
width: 100%;
height: calc(100% - #{$dash-graph-heading});
top: $dash-graph-heading;
left: 0;
padding: 0;
&:hover {
cursor: crosshair;
}
& > div:not(.graph-empty) {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
& > div:not(.graph-panel__refreshing) {
position: absolute;
width: 100%;
height: 100%;
padding: 8px 16px;
}
}
.graph-panel__refreshing {
top: (-$dash-graph-heading + 5px) !important;
}
}
.dash-graph--heading {
user-select: none !important;
-o-user-select: none !important;
-moz-user-select: none !important;
-webkit-user-select: none !important;
background-color: transparent;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: $dash-graph-heading;
padding: 0 16px;
margin: 0;
display: flex;
align-items: center;
border-radius: $radius;
font-weight: 600;
font-size: 13px;
color: $g14-chromium;
transition:
color 0.25s ease,
background-color 0.25s ease;
&:hover {
cursor: default;
}
}
.graph-panel__refreshing {
position: absolute;
top: -18px !important;
transform: translate(0,0);
right: 16px !important;
width: 16px;
height: 18px;
> div {
width: 4px;
height: 4px;
background-color: $g6-smoke;
border-radius: 50%;
position: absolute;
top: 50%;
transform: translate(-50%,-50%);
}
div:nth-child(1) {left: 0; animation: refreshingSpinnerA 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; }
div:nth-child(2) {left: 50%; animation: refreshingSpinnerB 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; }
div:nth-child(3) {left: 100%; animation: refreshingSpinnerC 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;}
}
/*
Dashboard Edit Mode
------------------------------------------------------
*/
.dashboard.dashboard-edit {
.dash-graph--heading:hover {
background-color: $g4-onyx;
color: $g18-cloud;
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
cursor: -moz-grab;
cursor: -webkit-grab;
}
.react-grid-placeholder {
@include gradient-diag-down($c-pool,$c-comet);
border: 0;
opacity: 0.3;
z-index: 2;
}
.react-grid-item {
&.resizing {
background-color: fade-out($g3-castle,0.09);
border-color: $c-pool;
border-image-slice: 3%;
border-image-repeat: initial;
border-image-outset: 0;
border-image-width: 2px;
border-image-source: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDY3IDc5LjE1Nzc0NywgMjAxNS8wMy8zMC0yMzo0MDo0MiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTg0NjVDRkVGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTg0NjVDRkZGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDoxODQ2NUNGQ0YwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDoxODQ2NUNGREYwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PpeetfIAAAMnSURBVHja7N1PatwwFMfxJ5NlKT1DIfQKWZfSA/Q0hexDL9McoOQAPUKglwhp6dZ9Ho/HfyTZs6l+b/E1GDm27IH5oH9Pyji9//7XfLtNZt88/eT722TzlvrFseXHaXFmypuO8vd5nmW6nyeNefrKfZv7i9f75blU/NzafXvns2dV7tl8zqsnT55+9f3Xjf/xwQ9+evou+xLB+N8Ydi4AX3z/6PnvOj94AEOGMV/rB4P00J2rKTC0GNOTPne0GWEwhv1NB0YYjNPWgREHI00gYMTAOIGAEQdjuKcDIw7GXGWBEQJjrLLACIORrFBlgaHDsG2VBYYWY1VlgaHHSH3WqIOhxLB1ow6GGmPRqIMRAeMMAkYUDFuGTsDQYwxP6MCIg1Hp9oKhwih0e8FQYthuLAuM5hj1WBYYEoxUjGWBIcOwrFEHQ4qxLiFgyDFOvSww4mCM8yFghMEoDgzB0GGk2owhGBoMq5UQMDQYxRIChg4ja0PA0GLYMrgIhh7jUkLAiIExV1lghMA4GBiC0RrjNIULRhyMysAQDBVGYWAIhhJjM6cOhhpjUULAiIAxr1wEIwTGPDAEIwTGWGWBEQajHu0FQ4JRjvaCIcPIo71gSDHW0V4w5Bj5SB0MKUZxoRwYOoxsPgQMLcZqPgQMPUaxUQdDh2HVcQgYEoxUHIeAIcPIqywwpBjrKgsMOcb8f+pghMDIwu9gaDFWI3Uw9Bg2N+pgRMA497LAiIJRXf0OhgajuPodDB3G1dFeMNpgXBXtBaMdxmG0F4y2GLvRXjDaY2wGhmCoMawU7QVDh5G20V4wtBjzwBCMEBiXVx6BEQPjsJcFRluM3V4WGO0xqr0sMDQYVuplgaHDWL1YEgw9hi17WWDoMVJ1ChcMCYYVp3DBkGFUl5KCocGw6deAwIiBYUfBRTDaYmTdXjC0GFYLLoKhwSj+cAAYOgzbBhfB0GKsgotg6DGuWrkIRjuMudsLRgiMsQ0BIwzG5ZVHYMTAmKqsVzBiYPj2Z+j2PoERAmM4/2MoIfe+v4Ahx3jx5H4AefYLd37q0Y9/g9EcY/jOHz11A3v+J8AA9wisahRCWTQAAAAASUVORK5CYII=);
z-index: 3;
& > .react-resizable-handle {
&:before, &:after {
background-color: $c-comet;
}
}
}
&.react-draggable-dragging {
background-color: fade-out($g3-castle,0.09);
border-color: $c-pool;
border-image-slice: 3%;
border-image-repeat: initial;
border-image-outset: 0;
border-image-width: 2px;
border-image-source: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDY3IDc5LjE1Nzc0NywgMjAxNS8wMy8zMC0yMzo0MDo0MiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTg0NjVDRkVGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTg0NjVDRkZGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDoxODQ2NUNGQ0YwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDoxODQ2NUNGREYwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PpeetfIAAAMnSURBVHja7N1PatwwFMfxJ5NlKT1DIfQKWZfSA/Q0hexDL9McoOQAPUKglwhp6dZ9Ho/HfyTZs6l+b/E1GDm27IH5oH9Pyji9//7XfLtNZt88/eT722TzlvrFseXHaXFmypuO8vd5nmW6nyeNefrKfZv7i9f75blU/NzafXvns2dV7tl8zqsnT55+9f3Xjf/xwQ9+evou+xLB+N8Ydi4AX3z/6PnvOj94AEOGMV/rB4P00J2rKTC0GNOTPne0GWEwhv1NB0YYjNPWgREHI00gYMTAOIGAEQdjuKcDIw7GXGWBEQJjrLLACIORrFBlgaHDsG2VBYYWY1VlgaHHSH3WqIOhxLB1ow6GGmPRqIMRAeMMAkYUDFuGTsDQYwxP6MCIg1Hp9oKhwih0e8FQYthuLAuM5hj1WBYYEoxUjGWBIcOwrFEHQ4qxLiFgyDFOvSww4mCM8yFghMEoDgzB0GGk2owhGBoMq5UQMDQYxRIChg4ja0PA0GLYMrgIhh7jUkLAiIExV1lghMA4GBiC0RrjNIULRhyMysAQDBVGYWAIhhJjM6cOhhpjUULAiIAxr1wEIwTGPDAEIwTGWGWBEQajHu0FQ4JRjvaCIcPIo71gSDHW0V4w5Bj5SB0MKUZxoRwYOoxsPgQMLcZqPgQMPUaxUQdDh2HVcQgYEoxUHIeAIcPIqywwpBjrKgsMOcb8f+pghMDIwu9gaDFWI3Uw9Bg2N+pgRMA497LAiIJRXf0OhgajuPodDB3G1dFeMNpgXBXtBaMdxmG0F4y2GLvRXjDaY2wGhmCoMawU7QVDh5G20V4wtBjzwBCMEBiXVx6BEQPjsJcFRluM3V4WGO0xqr0sMDQYVuplgaHDWL1YEgw9hi17WWDoMVJ1ChcMCYYVp3DBkGFUl5KCocGw6deAwIiBYUfBRTDaYmTdXjC0GFYLLoKhwSj+cAAYOgzbBhfB0GKsgotg6DGuWrkIRjuMudsLRgiMsQ0BIwzG5ZVHYMTAmKqsVzBiYPj2Z+j2PoERAmM4/2MoIfe+v4Ahx3jx5H4AefYLd37q0Y9/g9EcY/jOHz11A3v+J8AA9wisahRCWTQAAAAASUVORK5CYII=);
cursor: grabbing;
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
&:hover {
cursor: grabbing;
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
& > .dash-graph--heading,
& > .dash-graph--heading:hover {
background-color: $g4-onyx;
color: $g18-cloud;
cursor: grabbing;
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
}
&.cssTransforms {
transition-property: transform, border-color, background-color;
}
& > .react-resizable-handle {
background-image: none;
cursor: nwse-resize;
&:before,
&:after {
content: '';
display: block;
position: absolute;
height: 2px;
background-color: $g6-smoke;
transition: background-color 0.25s ease;
top: 50%;
left: 50%;
}
&:before {
width: 20px;
transform: translate(-50%,-50%) rotate(-45deg);
}
&:after {
width: 12px;
transform: translate(-3px,2px) rotate(-45deg);
}
&:hover {
&:before, &:after {
background-color: $c-comet;
}
}
}
}
}

View File

@ -3,29 +3,13 @@
----------------------------------------------
*/
@keyframes refreshingSpinnerA {
0% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; }
33% { transform: translate(-50%,-50%) scale(1,1); }
66% { transform: translate(-50%,-50%) scale(1,1); }
100% { transform: translate(-50%,-50%) scale(1,1); }
}
@keyframes refreshingSpinnerB {
0% { transform: translate(-50%,-50%) scale(1,1); }
33% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; }
66% { transform: translate(-50%,-50%) scale(1,1); }
100% { transform: translate(-50%,-50%) scale(1,1); }
}
@keyframes refreshingSpinnerC {
0% { transform: translate(-50%,-50%) scale(1,1); }
33% { transform: translate(-50%,-50%) scale(1,1); }
66% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; }
100% { transform: translate(-50%,-50%) scale(1,1); }
}
.graph-container.hosts-graph {
padding: 8px 16px;
.single-stat {
font-size: 32px;
font-size: 60px;
font-weight: 300;
color: $c-pool;
display: flex;
justify-content: center;
align-items: center;
@ -37,41 +21,8 @@
top: 0;
}
}
.graph-panel__refreshing {
position: absolute;
top: -18px !important;
transform: translate(0,0);
right: 16px !important;
width: 16px;
height: 18px;
> div {
width: 4px;
height: 4px;
background-color: $g6-smoke;
border-radius: 50%;
position: absolute;
top: 50%;
transform: translate(-50%,-50%);
}
div:nth-child(1) {left: 0; animation: refreshingSpinnerA 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; }
div:nth-child(2) {left: 50%; animation: refreshingSpinnerB 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; }
div:nth-child(3) {left: 100%; animation: refreshingSpinnerC 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;}
}
}
.hosts-graph-heading {
display: block;
width: 100%;
margin: 0;
background-color: $g3-castle;
padding: 14px 16px 2px 16px;
font-weight: 600;
font-size: 13px;
color: $g14-chromium;
border-radius: 4px 4px 0 0;
}
.host-list--active-source {
text-transform: uppercase;
font-size: 15px;
@ -89,8 +40,6 @@
}
/* Hacky way to ensure that legends cannot be obscured by neighboring graphs */
.react-grid-item {
&:hover {
z-index: 8999;
}
div:not(.dashboard-edit) .react-grid-item:hover {
z-index: 8999;
}

View File

@ -74,13 +74,14 @@
line-height: 30px !important;
height: 30px !important;
padding: 0 9px !important;
.icon {
font-size: 16px;
margin: 0 4px 0 0 ;
}
}
.btn.btn-xs .icon {
a.btn.btn-sm > span.icon,
div.btn.btn-sm > span.icon,
button.btn.btn-sm > span.icon {
font-size: 16px;
margin: 0 4px 0 0 ;
}
.btn.btn-xs > .icon {
position: relative;
top: -1px;
}