diff --git a/CHANGELOG.md b/CHANGELOG.md index f69747fd30..d615850922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/server/dashboards.go b/server/dashboards.go index c56aa2c75f..5eef491c92 100644 --- a/server/dashboards.go +++ b/server/dashboards.go @@ -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 + } +} diff --git a/server/dashboards_test.go b/server/dashboards_test.go new file mode 100644 index 0000000000..d362f94fe9 --- /dev/null +++ b/server/dashboards_test.go @@ -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) + } + } +} diff --git a/server/mux.go b/server/mux.go index 5d818d9f40..6ab6ce4356 100644 --- a/server/mux.go +++ b/server/mux.go @@ -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 { diff --git a/server/swagger.json b/server/swagger.json index 57ff0e54ad..e5a0f15dab 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -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.", diff --git a/ui/.eslintrc b/ui/.eslintrc index 360af5a034..cb72bd92d4 100644 --- a/ui/.eslintrc +++ b/ui/.eslintrc @@ -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, diff --git a/ui/spec/dashboards/reducers/uiSpec.js b/ui/spec/dashboards/reducers/uiSpec.js new file mode 100644 index 0000000000..3fb00e3922 --- /dev/null +++ b/ui/spec/dashboards/reducers/uiSpec.js @@ -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) + }) +}) diff --git a/ui/src/App.js b/ui/src/App.js index 0c1acfa8f9..f19438b16a 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -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 (
- + {this.renderNotifications()} {this.props.children && React.cloneElement(this.props.children, { addFlashMessage: this.handleNotification, diff --git a/ui/src/dashboards/actions/index.js b/ui/src/dashboards/actions/index.js new file mode 100644 index 0000000000..8deeba704b --- /dev/null +++ b/ui/src/dashboards/actions/index.js @@ -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, + }, + } +} diff --git a/ui/src/dashboards/apis/index.js b/ui/src/dashboards/apis/index.js index f0106f76bb..c4e6303fc9 100644 --- a/ui/src/dashboards/apis/index.js +++ b/ui/src/dashboards/apis/index.js @@ -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, + }); +} diff --git a/ui/src/dashboards/components/Dashboard.js b/ui/src/dashboards/components/Dashboard.js new file mode 100644 index 0000000000..46eb173b05 --- /dev/null +++ b/ui/src/dashboards/components/Dashboard.js @@ -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 ( +
+
+ {isEditMode ? : null} + {Dashboard.renderDashboard(dashboard, timeRange, source, onPositionChange)} +
+
+ ) +} + +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 ( + + ) +} + +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 diff --git a/ui/src/dashboards/components/DashboardHeader.js b/ui/src/dashboards/components/DashboardHeader.js new file mode 100644 index 0000000000..60ff5e8ffc --- /dev/null +++ b/ui/src/dashboards/components/DashboardHeader.js @@ -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 : ( +
+
+
+ {buttonText && +
+ +
    + {children} +
+
+ } + {headerText && +

Kubernetes Dashboard

+ } +
+
+ {sourceID ? + + +  Edit + : null + } +
+ + Graph Tips +
+ + +
+ +
+
+
+
+) + +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 diff --git a/ui/src/dashboards/components/DashboardHeaderEdit.js b/ui/src/dashboards/components/DashboardHeaderEdit.js new file mode 100644 index 0000000000..987ed02f87 --- /dev/null +++ b/ui/src/dashboards/components/DashboardHeaderEdit.js @@ -0,0 +1,36 @@ +import React, {PropTypes} from 'react' + +const DashboardEditHeader = ({ + dashboard, + onSave, +}) => ( +
+
+
+ +
+
+
+ Save +
+
+
+
+) + +const { + shape, + func, +} = PropTypes + +DashboardEditHeader.propTypes = { + dashboard: shape({}), + onSave: func.isRequired, +} + +export default DashboardEditHeader diff --git a/ui/src/dashboards/components/VisualizationSelector.js b/ui/src/dashboards/components/VisualizationSelector.js new file mode 100644 index 0000000000..f44518d387 --- /dev/null +++ b/ui/src/dashboards/components/VisualizationSelector.js @@ -0,0 +1,24 @@ +import React from 'react' + +const VisualizationSelector = () => ( +
+
+ VISUALIZATIONS +
+ Line Graph +
+
+ SingleStat +
+
+
+) + +export default VisualizationSelector diff --git a/ui/src/dashboards/constants/index.js b/ui/src/dashboards/constants/index.js new file mode 100644 index 0000000000..8e751072ee --- /dev/null +++ b/ui/src/dashboards/constants/index.js @@ -0,0 +1,12 @@ +export const EMPTY_DASHBOARD = { + id: 0, + name: '', + cells: [ + { + x: 0, + y: 0, + queries: [], + name: 'Loading...', + }, + ], +} diff --git a/ui/src/dashboards/containers/DashboardPage.js b/ui/src/dashboards/containers/DashboardPage.js index 135e7ebc59..c83c920202 100644 --- a/ui/src/dashboards/containers/DashboardPage.js +++ b/ui/src/dashboards/containers/DashboardPage.js @@ -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 ( - - ); + 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 (
-
-
-
-
- -
    - {(dashboards).map((d, i) => { - return ( -
  • - - {d.name} - -
  • - ); - })} -
-
-
-
-
- - Graph Tips -
- - -
-
-
-
-
- { dashboard ? this.renderDashboard(dashboard) : '' } -
-
+ { + isEditMode ? + {}} /> : +
+ {(dashboards).map((d, i) => { + return ( +
  • + + {d.name} + +
  • + ); + })} +
    + } +
    ); }, }); -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); diff --git a/ui/src/dashboards/containers/DashboardsPage.js b/ui/src/dashboards/containers/DashboardsPage.js index 4bc59e3742..6ffc66d09d 100644 --- a/ui/src/dashboards/containers/DashboardsPage.js +++ b/ui/src/dashboards/containers/DashboardsPage.js @@ -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 ( - + {dashboard.name} @@ -83,6 +84,13 @@ const DashboardsPage = React.createClass({ ); }) } + + + + {'Kubernetes'} + + +
    diff --git a/ui/src/dashboards/reducers/ui.js b/ui/src/dashboards/reducers/ui.js new file mode 100644 index 0000000000..72c3392945 --- /dev/null +++ b/ui/src/dashboards/reducers/ui.js @@ -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; +} diff --git a/ui/src/data_explorer/components/Visualization.js b/ui/src/data_explorer/components/Visualization.js index d2f94d99a3..b88972a9e1 100644 --- a/ui/src/data_explorer/components/Visualization.js +++ b/ui/src/data_explorer/components/Visualization.js @@ -73,7 +73,7 @@ const Visualization = React.createClass({ -
    +
    {isGraphInView ? ( -
    -
    -
    -
    - -
      - {Object.keys(hosts).map((host, i) => { - return ( -
    • - - {host} - -
    • - ); - })} -
    -
    -
    -
    -
    - - Graph Tips -
    - - -
    -
    -
    -
    -
    + + {Object.keys(hosts).map((host, i) => { + return ( +
  • + + {host} + +
  • + ); + })} +
    +
    +
    { (layouts.length > 0) ? this.renderLayouts(layouts) : '' }
    @@ -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) diff --git a/ui/src/index.js b/ui/src/index.js index fcaa209f35..ec4ae179e3 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -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({ + diff --git a/ui/src/kubernetes/components/KubernetesDashboard.js b/ui/src/kubernetes/components/KubernetesDashboard.js index 086b821e8c..2c4afc5b41 100644 --- a/ui/src/kubernetes/components/KubernetesDashboard.js +++ b/ui/src/kubernetes/components/KubernetesDashboard.js @@ -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 = (
    @@ -68,23 +79,18 @@ export const KubernetesPage = React.createClass({ return (
    -
    -
    -
    -

    Kubernetes Dashboard

    -
    -
    -
    - - Graph Tips -
    - - -
    -
    -
    -
    -
    + +
    +
    {layouts.length ? this.renderLayouts(layouts) : emptyState}
    @@ -92,4 +98,5 @@ export const KubernetesPage = React.createClass({ ); }, }); -export default KubernetesPage; + +export default KubernetesDashboard; diff --git a/ui/src/kubernetes/containers/KubernetesPage.js b/ui/src/kubernetes/containers/KubernetesPage.js index b67241f628..213bc8a06c 100644 --- a/ui/src/kubernetes/containers/KubernetesPage.js +++ b/ui/src/kubernetes/containers/KubernetesPage.js @@ -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 ( - + ); }, }); -export default KubernetesPage; +const mapStateToProps = (state) => ({ + inPresentationMode: state.appUI.presentationMode, +}) + +const mapDispatchToProps = (dispatch) => ({ + handleClickPresentationButton: presentationButtonDispatcher(dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(KubernetesPage); diff --git a/ui/src/shared/actions/notifications.js b/ui/src/shared/actions/notifications.js index 7551e4facf..8e2b419110 100644 --- a/ui/src/shared/actions/notifications.js +++ b/ui/src/shared/actions/notifications.js @@ -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', diff --git a/ui/src/shared/actions/ui.js b/ui/src/shared/actions/ui.js new file mode 100644 index 0000000000..740566beb9 --- /dev/null +++ b/ui/src/shared/actions/ui.js @@ -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) + } +} diff --git a/ui/src/shared/components/Dygraph.js b/ui/src/shared/components/Dygraph.js index 12895f8624..f819e77999 100644 --- a/ui/src/shared/components/Dygraph.js +++ b/ui/src/shared/components/Dygraph.js @@ -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() { diff --git a/ui/src/shared/components/LayoutRenderer.js b/ui/src/shared/components/LayoutRenderer.js index f31b194fd3..e1c2df5e77 100644 --- a/ui/src/shared/components/LayoutRenderer.js +++ b/ui/src/shared/components/LayoutRenderer.js @@ -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 (
    -

    {cell.name}

    -
    +

    {cell.name || `Graph`}

    +
    @@ -111,8 +113,8 @@ export const LayoutRenderer = React.createClass({ return (
    -

    {cell.name}

    -
    +

    {cell.name || `Graph`}

    +
    { + 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 ( - + {this.generateVisualizations()} ); }, + + + 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; diff --git a/ui/src/shared/components/LineGraph.js b/ui/src/shared/components/LineGraph.js index dc67d37e17..e26faac59c 100644 --- a/ui/src/shared/components/LineGraph.js +++ b/ui/src/shared/components/LineGraph.js @@ -103,7 +103,7 @@ export default React.createClass({
    {isRefreshing ? this.renderSpinner() : null} () => { + dispatch(delayEnablePresentationMode()) + dispatch(publishNotification('success', 'Press ESC to disable presentation mode.')) + dispatch(delayDismissNotification('success', PRESENTATION_MODE_NOTIFICATION_DELAY)) +} diff --git a/ui/src/shared/reducers/index.js b/ui/src/shared/reducers/index.js index 500de90d67..874f88ceaa 100644 --- a/ui/src/shared/reducers/index.js +++ b/ui/src/shared/reducers/index.js @@ -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, diff --git a/ui/src/shared/reducers/ui.js b/ui/src/shared/reducers/ui.js new file mode 100644 index 0000000000..77a2f77a8a --- /dev/null +++ b/ui/src/shared/reducers/ui.js @@ -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 +} diff --git a/ui/src/side_nav/components/SideNav.js b/ui/src/side_nav/components/SideNav.js index af186e3191..72c45d1bac 100644 --- a/ui/src/side_nav/components/SideNav.js +++ b/ui/src/side_nav/components/SideNav.js @@ -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 : (
    - - - Host List - Kubernetes Dashboard + + - - Explorer - Dashboards + - + + + + Alert History Kapacitor Rules - + InfluxDB Kapacitor diff --git a/ui/src/side_nav/containers/SideNavApp.js b/ui/src/side_nav/containers/SideNavApp.js index b19ab5f4b0..a93bffdf72 100644 --- a/ui/src/side_nav/containers/SideNavApp.js +++ b/ui/src/side_nav/containers/SideNavApp.js @@ -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 ( ); }, - }); function mapStateToProps(state) { return { me: state.me, + inPresentationMode: state.appUI.presentationMode, }; } diff --git a/ui/src/store/configureStore.js b/ui/src/store/configureStore.js index 286e96e47b..85b4a6e11a 100644 --- a/ui/src/store/configureStore.js +++ b/ui/src/store/configureStore.js @@ -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; diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index 901fa020c8..83e08cd7c6 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -45,6 +45,7 @@ @import 'pages/hosts'; @import 'pages/kapacitor'; @import 'pages/data-explorer'; +@import 'pages/dashboards'; // TODO @import 'unsorted'; diff --git a/ui/src/style/components/dygraphs.scss b/ui/src/style/components/dygraphs.scss index 991c3a9e67..b6653fff93 100644 --- a/ui/src/style/components/dygraphs.scss +++ b/ui/src/style/components/dygraphs.scss @@ -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; } } diff --git a/ui/src/style/layout/page.scss b/ui/src/style/layout/page.scss index bf19239cfc..0034b3e96a 100644 --- a/ui/src/style/layout/page.scss +++ b/ui/src/style/layout/page.scss @@ -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; } } -} \ No newline at end of file +} diff --git a/ui/src/style/mixins/mixins.scss b/ui/src/style/mixins/mixins.scss index 1ee71b0d8d..9d03a2567b 100644 --- a/ui/src/style/mixins/mixins.scss +++ b/ui/src/style/mixins/mixins.scss @@ -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; } -} \ No newline at end of file +} diff --git a/ui/src/style/pages/dashboards.scss b/ui/src/style/pages/dashboards.scss new file mode 100644 index 0000000000..30c36980b1 --- /dev/null +++ b/ui/src/style/pages/dashboards.scss @@ -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; + } + } + } + } +} diff --git a/ui/src/style/pages/hosts.scss b/ui/src/style/pages/hosts.scss index 89aeb0e402..0f658a28cb 100644 --- a/ui/src/style/pages/hosts.scss +++ b/ui/src/style/pages/hosts.scss @@ -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; } diff --git a/ui/src/style/theme/theme-dark.scss b/ui/src/style/theme/theme-dark.scss index 86acb32587..c28053f900 100644 --- a/ui/src/style/theme/theme-dark.scss +++ b/ui/src/style/theme/theme-dark.scss @@ -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; }