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 &&
+
+ }
+ {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,
+}) => (
+
+)
+
+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}
-
-
- );
- })}
-
-
-
-
-
-
-
-
- { 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}
-
-
- );
- })}
-
-
-
-
-
-
-
-
+
+ {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
-
-
-
-
-
-
+
+
+
{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;
}