Browse Source

Merge branch 'export-dashboard'

Conflicts:
	conf/defaults.ini
	pkg/setting/setting.go
	public/app/core/components/grafana_app.ts
	public/app/core/core.ts
	public/app/features/dashboard/dashboardCtrl.js
Torkel Ödegaard 9 years ago
parent
commit
ec0b09450c
66 changed files with 1902 additions and 490 deletions
  1. 0 1
      .floo
  2. 0 1
      .flooignore
  3. 1 1
      .jscs.json
  4. 4 1
      conf/defaults.ini
  5. 5 1
      conf/sample.ini
  6. 0 2
      docs/sources/reference/dashboard.md
  7. 2 0
      pkg/api/api.go
  8. 5 1
      pkg/api/dtos/plugins.go
  9. 10 4
      pkg/api/gnetproxy.go
  10. 1 1
      pkg/api/index.go
  11. 2 1
      pkg/api/plugins.go
  12. 5 0
      pkg/models/dashboards.go
  13. 15 16
      pkg/plugins/dashboard_importer.go
  14. 19 14
      pkg/plugins/dashboards.go
  15. 5 0
      pkg/services/sqlstore/migrations/dashboard_mig.go
  16. 5 0
      pkg/setting/setting.go
  17. 3 0
      public/app/core/components/grafana_app.ts
  18. 8 6
      public/app/core/components/search/search.html
  19. 1 3
      public/app/core/components/search/search.ts
  20. 32 0
      public/app/core/components/wizard/wizard.html
  21. 73 0
      public/app/core/components/wizard/wizard.ts
  22. 2 1
      public/app/core/core.ts
  23. 24 22
      public/app/core/directives/dash_edit_link.js
  24. 0 46
      public/app/core/directives/dash_upload.js
  25. 1 1
      public/app/core/profiler.ts
  26. 0 12
      public/app/core/routes/dashboard_loaders.js
  27. 6 8
      public/app/core/routes/routes.ts
  28. 5 5
      public/app/core/services/datasource_srv.js
  29. 0 31
      public/app/core/services/util_srv.js
  30. 43 0
      public/app/core/services/util_srv.ts
  31. 5 2
      public/app/features/dashboard/all.js
  32. 11 0
      public/app/features/dashboard/dash_list_ctrl.ts
  33. 0 150
      public/app/features/dashboard/dashboardCtrl.js
  34. 2 1
      public/app/features/dashboard/dashboardSrv.js
  35. 145 0
      public/app/features/dashboard/dashboard_ctrl.ts
  36. 10 3
      public/app/features/dashboard/dashnav/dashnav.html
  37. 7 7
      public/app/features/dashboard/dashnav/dashnav.ts
  38. 188 0
      public/app/features/dashboard/dynamic_dashboard_srv.ts
  39. 29 0
      public/app/features/dashboard/export/export_modal.html
  40. 53 0
      public/app/features/dashboard/export/export_modal.ts
  41. 135 0
      public/app/features/dashboard/export/exporter.ts
  42. 130 0
      public/app/features/dashboard/import/dash_import.html
  43. 180 0
      public/app/features/dashboard/import/dash_import.ts
  44. 0 4
      public/app/features/dashboard/keybindings.js
  45. 10 0
      public/app/features/dashboard/partials/dash_list.html
  46. 3 11
      public/app/features/dashboard/partials/migrate.html
  47. 9 5
      public/app/features/dashboard/partials/settings.html
  48. 101 87
      public/app/features/dashboard/partials/shareModal.html
  49. 6 2
      public/app/features/dashboard/shareModalCtrl.js
  50. 84 0
      public/app/features/dashboard/specs/dash_import_ctrl_specs.ts
  51. 264 0
      public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts
  52. 142 0
      public/app/features/dashboard/specs/exporter_specs.ts
  53. 0 2
      public/app/features/dashboard/submenu/submenu.ts
  54. 61 0
      public/app/features/dashboard/upload.ts
  55. 6 6
      public/app/features/plugins/import_list/import_list.html
  56. 3 7
      public/app/features/plugins/import_list/import_list.ts
  57. 0 1
      public/app/features/plugins/plugin_edit_ctrl.ts
  58. 0 4
      public/app/partials/help_modal.html
  59. 0 1
      public/dashboards/home.json
  60. 0 1
      public/dashboards/template_vars.json
  61. 3 3
      public/sass/_variables.dark.scss
  62. 10 0
      public/sass/components/_dropdown.scss
  63. 4 0
      public/sass/components/_gf-form.scss
  64. 14 11
      public/sass/components/_modals.scss
  65. 2 0
      public/sass/utils/_validation.scss
  66. 8 3
      public/vendor/angular-other/angular-strap.js

+ 0 - 1
.floo

@@ -1,4 +1,3 @@
 {
   "url": "https://floobits.com/raintank/grafana"
 }
-

+ 0 - 1
.flooignore

@@ -10,4 +10,3 @@ data/
 vendor/
 public_gen/
 dist/
-

+ 1 - 1
.jscs.json

@@ -10,4 +10,4 @@
     "disallowSpacesInsideArrayBrackets": true,
     "disallowSpacesInsideParentheses": true,
     "validateIndentation": 2
-}
+}

+ 4 - 1
conf/defaults.ini

@@ -347,12 +347,15 @@ global_api_key = -1
 global_session = -1
 
 #################################### Internal Grafana Metrics ##########################
+# Metrics available at HTTP API Url /api/metrics
 [metrics]
 enabled           = true
 interval_seconds  = 60
 
+# Send internal Grafana metrics to graphite
 ; [metrics.graphite]
 ; address = localhost:2003
 ; prefix = prod.grafana.%(instance_name)s.
 
-
+[grafana_net]
+url = https://grafana.net

+ 5 - 1
conf/sample.ini

@@ -294,6 +294,7 @@ check_for_updates = true
 ;path = /var/lib/grafana/dashboards
 
 #################################### Internal Grafana Metrics ##########################
+# Metrics available at HTTP API Url /api/metrics
 [metrics]
 # Disable / Enable internal metrics
 ;enabled           = true
@@ -306,4 +307,7 @@ check_for_updates = true
 ; address = localhost:2003
 ; prefix = prod.grafana.%(instance_name)s.
 
-
+#################################### Internal Grafana Metrics ##########################
+# Url used to to import dashboards directly from Grafana.net
+[grafana_net]
+url = https://grafana.net

+ 0 - 2
docs/sources/reference/dashboard.md

@@ -26,7 +26,6 @@ When a user creates a new dashboard, a new dashboard JSON object is initialized
 {
   "id": null,
   "title": "New dashboard",
-  "originalTitle": "New dashboard",
   "tags": [],
   "style": "dark",
   "timezone": "browser",
@@ -59,7 +58,6 @@ Each field in the dashboard JSON is explained below with its usage:
 | ---- | ----- |
 | **id** | unique dashboard id, an integer |
 | **title** | current title of dashboard |
-| **originalTitle** | title of dashboard when saved for the first time |
 | **tags** | tags associated with dashboard, an array of strings |
 | **style** | theme of dashboard, i.e. `dark` or `light` |
 | **timezone** | timezone of dashboard, i.e. `utc` or `browser` |

+ 2 - 0
pkg/api/api.go

@@ -55,6 +55,8 @@ func Register(r *macaron.Macaron) {
 
 	r.Get("/dashboard/*", reqSignedIn, Index)
 	r.Get("/dashboard-solo/*", reqSignedIn, Index)
+	r.Get("/import/dashboard", reqSignedIn, Index)
+	r.Get("/dashboards/*", reqSignedIn, Index)
 
 	r.Get("/playlists/", reqSignedIn, Index)
 	r.Get("/playlists/*", reqSignedIn, Index)

+ 5 - 1
pkg/api/dtos/plugins.go

@@ -1,6 +1,9 @@
 package dtos
 
-import "github.com/grafana/grafana/pkg/plugins"
+import (
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/plugins"
+)
 
 type PluginSetting struct {
 	Name          string                      `json:"name"`
@@ -50,5 +53,6 @@ type ImportDashboardCommand struct {
 	PluginId  string                         `json:"pluginId"`
 	Path      string                         `json:"path"`
 	Overwrite bool                           `json:"overwrite"`
+	Dashboard *simplejson.Json               `json:"dashboard"`
 	Inputs    []plugins.ImportDashboardInput `json:"inputs"`
 }

+ 10 - 4
pkg/api/gnetproxy.go

@@ -5,9 +5,12 @@ import (
 	"net"
 	"net/http"
 	"net/http/httputil"
+	"net/url"
 	"time"
 
+	"github.com/Unknwon/log"
 	"github.com/grafana/grafana/pkg/middleware"
+	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
 )
 
@@ -22,12 +25,15 @@ var gNetProxyTransport = &http.Transport{
 }
 
 func ReverseProxyGnetReq(proxyPath string) *httputil.ReverseProxy {
+	url, _ := url.Parse(setting.GrafanaNetUrl)
+
 	director := func(req *http.Request) {
-		req.URL.Scheme = "https"
-		req.URL.Host = "grafana.net"
-		req.Host = "grafana.net"
+		req.URL.Scheme = url.Scheme
+		req.URL.Host = url.Host
+		req.Host = url.Host
 
-		req.URL.Path = util.JoinUrlFragments("https://grafana.net/api", proxyPath)
+		req.URL.Path = util.JoinUrlFragments(url.Path+"/api", proxyPath)
+		log.Info("Url: %v", req.URL.Path)
 
 		// clear cookie headers
 		req.Header.Del("Cookie")

+ 1 - 1
pkg/api/index.go

@@ -69,7 +69,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 	if c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR {
 		dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{Divider: true})
 		dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{Text: "New", Icon: "fa fa-plus", Url: setting.AppSubUrl + "/dashboard/new"})
-		dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{Text: "Import", Icon: "fa fa-download", Url: setting.AppSubUrl + "/import/dashboard"})
+		dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{Text: "Import", Icon: "fa fa-download", Url: setting.AppSubUrl + "/dashboard/new/?editview=import"})
 	}
 
 	data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{

+ 2 - 1
pkg/api/plugins.go

@@ -168,10 +168,11 @@ func ImportDashboard(c *middleware.Context, apiCmd dtos.ImportDashboardCommand)
 		Path:      apiCmd.Path,
 		Inputs:    apiCmd.Inputs,
 		Overwrite: apiCmd.Overwrite,
+		Dashboard: apiCmd.Dashboard,
 	}
 
 	if err := bus.Dispatch(&cmd); err != nil {
-		return ApiError(500, "Failed to install dashboard", err)
+		return ApiError(500, "Failed to import dashboard", err)
 	}
 
 	return Json(200, cmd.Result)

+ 5 - 0
pkg/models/dashboards.go

@@ -29,6 +29,7 @@ type Dashboard struct {
 	Id      int64
 	Slug    string
 	OrgId   int64
+	GnetId  int64
 	Version int
 
 	Created time.Time
@@ -77,6 +78,10 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard {
 		dash.Updated = time.Now()
 	}
 
+	if gnetId, err := dash.Data.Get("gnetId").Float64(); err == nil {
+		dash.GnetId = int64(gnetId)
+	}
+
 	return dash
 }
 

+ 15 - 16
pkg/plugins/dashboard_importer.go

@@ -11,6 +11,7 @@ import (
 )
 
 type ImportDashboardCommand struct {
+	Dashboard *simplejson.Json
 	Path      string
 	Inputs    []ImportDashboardInput
 	Overwrite bool
@@ -41,17 +42,15 @@ func init() {
 }
 
 func ImportDashboard(cmd *ImportDashboardCommand) error {
-	plugin, exists := Plugins[cmd.PluginId]
-
-	if !exists {
-		return PluginNotFoundError{cmd.PluginId}
-	}
-
 	var dashboard *m.Dashboard
 	var err error
 
-	if dashboard, err = loadPluginDashboard(plugin, cmd.Path); err != nil {
-		return err
+	if cmd.PluginId != "" {
+		if dashboard, err = loadPluginDashboard(cmd.PluginId, cmd.Path); err != nil {
+			return err
+		}
+	} else {
+		dashboard = m.NewDashboardFromJson(cmd.Dashboard)
 	}
 
 	evaluator := &DashTemplateEvaluator{
@@ -76,13 +75,13 @@ func ImportDashboard(cmd *ImportDashboardCommand) error {
 	}
 
 	cmd.Result = &PluginDashboardInfoDTO{
-		PluginId:          cmd.PluginId,
-		Title:             dashboard.Title,
-		Path:              cmd.Path,
-		Revision:          dashboard.GetString("revision", "1.0"),
-		InstalledUri:      "db/" + saveCmd.Result.Slug,
-		InstalledRevision: dashboard.GetString("revision", "1.0"),
-		Installed:         true,
+		PluginId:         cmd.PluginId,
+		Title:            dashboard.Title,
+		Path:             cmd.Path,
+		Revision:         dashboard.Data.Get("revision").MustInt64(1),
+		ImportedUri:      "db/" + saveCmd.Result.Slug,
+		ImportedRevision: dashboard.Data.Get("revision").MustInt64(1),
+		Imported:         true,
 	}
 
 	return nil
@@ -110,7 +109,7 @@ func (this *DashTemplateEvaluator) findInput(varName string, varType string) *Im
 func (this *DashTemplateEvaluator) Eval() (*simplejson.Json, error) {
 	this.result = simplejson.New()
 	this.variables = make(map[string]string)
-	this.varRegex, _ = regexp.Compile(`(\$\{\w+\})`)
+	this.varRegex, _ = regexp.Compile(`(\$\{.+\})`)
 
 	// check that we have all inputs we need
 	for _, inputDef := range this.template.Get("__inputs").MustArray() {

+ 19 - 14
pkg/plugins/dashboards.go

@@ -10,14 +10,14 @@ import (
 )
 
 type PluginDashboardInfoDTO struct {
-	PluginId          string `json:"pluginId"`
-	Title             string `json:"title"`
-	Installed         bool   `json:"installed"`
-	InstalledUri      string `json:"installedUri"`
-	InstalledRevision string `json:"installedRevision"`
-	Revision          string `json:"revision"`
-	Description       string `json:"description"`
-	Path              string `json:"path"`
+	PluginId         string `json:"pluginId"`
+	Title            string `json:"title"`
+	Imported         bool   `json:"imported"`
+	ImportedUri      string `json:"importedUri"`
+	ImportedRevision int64  `json:"importedRevision"`
+	Revision         int64  `json:"revision"`
+	Description      string `json:"description"`
+	Path             string `json:"path"`
 }
 
 func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDTO, error) {
@@ -42,7 +42,12 @@ func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDT
 	return result, nil
 }
 
-func loadPluginDashboard(plugin *PluginBase, path string) (*m.Dashboard, error) {
+func loadPluginDashboard(pluginId, path string) (*m.Dashboard, error) {
+	plugin, exists := Plugins[pluginId]
+
+	if !exists {
+		return nil, PluginNotFoundError{pluginId}
+	}
 
 	dashboardFilePath := filepath.Join(plugin.PluginDir, path)
 	reader, err := os.Open(dashboardFilePath)
@@ -66,14 +71,14 @@ func getDashboardImportStatus(orgId int64, plugin *PluginBase, path string) (*Pl
 	var dashboard *m.Dashboard
 	var err error
 
-	if dashboard, err = loadPluginDashboard(plugin, path); err != nil {
+	if dashboard, err = loadPluginDashboard(plugin.Id, path); err != nil {
 		return nil, err
 	}
 
 	res.Path = path
 	res.PluginId = plugin.Id
 	res.Title = dashboard.Title
-	res.Revision = dashboard.GetString("revision", "1.0")
+	res.Revision = dashboard.Data.Get("revision").MustInt64(1)
 
 	query := m.GetDashboardQuery{OrgId: orgId, Slug: dashboard.Slug}
 
@@ -82,9 +87,9 @@ func getDashboardImportStatus(orgId int64, plugin *PluginBase, path string) (*Pl
 			return nil, err
 		}
 	} else {
-		res.Installed = true
-		res.InstalledUri = "db/" + query.Result.Slug
-		res.InstalledRevision = query.Result.GetString("revision", "1.0")
+		res.Imported = true
+		res.ImportedUri = "db/" + query.Result.Slug
+		res.ImportedRevision = query.Result.Data.Get("revision").MustInt64(1)
 	}
 
 	return res, nil

+ 5 - 0
pkg/services/sqlstore/migrations/dashboard_mig.go

@@ -102,4 +102,9 @@ func addDashboardMigration(mg *Migrator) {
 	mg.AddMigration("Add column created_by in dashboard - v2", NewAddColumnMigration(dashboardV2, &Column{
 		Name: "created_by", Type: DB_Int, Nullable: true,
 	}))
+
+	// add column to store gnetId
+	mg.AddMigration("Add column gnetId in dashboard", NewAddColumnMigration(dashboardV2, &Column{
+		Name: "gnet_id", Type: DB_BigInt, Nullable: true,
+	}))
 }

+ 5 - 0
pkg/setting/setting.go

@@ -141,6 +141,9 @@ var (
 
 	// logger
 	logger log.Logger
+
+	// Grafana.NET URL
+	GrafanaNetUrl string
 )
 
 type CommandLineArgs struct {
@@ -520,6 +523,8 @@ func NewConfigContext(args *CommandLineArgs) error {
 		log.Warn("require_email_validation is enabled but smpt is disabled")
 	}
 
+	GrafanaNetUrl = Cfg.Section("grafana.net").Key("url").MustString("https://grafana.net")
+
 	return nil
 }
 

+ 3 - 0
public/app/core/components/grafana_app.ts

@@ -5,8 +5,10 @@ import store from 'app/core/store';
 import _ from 'lodash';
 import angular from 'angular';
 import $ from 'jquery';
+
 import coreModule from 'app/core/core_module';
 import {profiler} from 'app/core/profiler';
+import appEvents from 'app/core/app_events';
 
 export class GrafanaCtrl {
 
@@ -44,6 +46,7 @@ export class GrafanaCtrl {
 
     $rootScope.appEvent = function(name, payload) {
       $rootScope.$emit(name, payload);
+      appEvents.emit(name, payload);
     };
 
     $rootScope.colors = [

+ 8 - 6
public/app/core/components/search/search.html

@@ -62,14 +62,16 @@
 	</div>
 
 	<div class="search-button-row">
-		<button class="btn btn-inverse pull-left" ng-click="ctrl.newDashboard()" ng-show="ctrl.contextSrv.isEditor">
+		<a class="btn btn-inverse pull-left" href="dashboard/new" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">
 			<i class="fa fa-plus"></i>
-			New
-		</button>
-		<a class="btn btn-inverse pull-left" href="import/dashboard" ng-show="ctrl.contextSrv.isEditor">
-			<i class="fa fa-download"></i>
+			Create New
+		</a>
+
+		<a class="btn btn-inverse pull-left" href="dashboard/new/?editview=import" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">
+			<i class="fa fa-upload"></i>
 			Import
 		</a>
-		<div class="clearfix"></div>
+
+ 		<div class="clearfix"></div>
 	</div>
 </div>

+ 1 - 3
public/app/core/components/search/search.ts

@@ -5,6 +5,7 @@ import config from 'app/core/config';
 import _ from 'lodash';
 import $ from 'jquery';
 import coreModule from '../../core_module';
+import appEvents from 'app/core/app_events';
 
 export class SearchCtrl {
   isOpen: boolean;
@@ -148,9 +149,6 @@ export class SearchCtrl {
     this.searchDashboards();
   };
 
-  newDashboard() {
-    this.$location.url('dashboard/new');
-  };
 }
 
 export function searchDirective() {

+ 32 - 0
public/app/core/components/wizard/wizard.html

@@ -0,0 +1,32 @@
+<div class="modal-body">
+	<div class="modal-header">
+		<h2 class="modal-header-title">
+			<i class="fa fa-cog fa-spin"></i>
+			<span class="p-l-1">{{model.name}}</span>
+		</h2>
+
+		<a class="modal-header-close" ng-click="dismiss();">
+			<i class="fa fa-remove"></i>
+		</a>
+	</div>
+
+	<div class="modal-content">
+    <div ng-if="activeStep">
+
+    </div>
+
+		<!-- <table class="filter&#45;table"> -->
+		<!-- 	<tbody> -->
+		<!-- 		<tr ng&#45;repeat="step in model.steps"> -->
+		<!-- 			<td>{{step.name}}</td> -->
+		<!-- 			<td>{{step.status}}</td> -->
+		<!-- 			<td width="1%"> -->
+		<!-- 				<i class="fa fa&#45;check" style="color: #39A039"></i> -->
+		<!-- 			</td> -->
+		<!-- 		</tr> -->
+		<!-- 	</tbody> -->
+		<!-- </table> -->
+	</div>
+
+</div>
+

+ 73 - 0
public/app/core/components/wizard/wizard.ts

@@ -0,0 +1,73 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import config from 'app/core/config';
+import _ from 'lodash';
+import $ from 'jquery';
+
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+
+export class WizardSrv {
+  /** @ngInject */
+  constructor() {
+  }
+}
+
+export interface WizardStep {
+  name: string;
+  type: string;
+  process: any;
+}
+
+export class SelectOptionStep {
+  type: string;
+  name: string;
+  fulfill: any;
+
+  constructor() {
+    this.type = 'select';
+  }
+
+  process() {
+    return new Promise((fulfill, reject) => {
+
+    });
+  }
+}
+
+export class WizardFlow {
+  name: string;
+  steps: WizardStep[];
+
+  constructor(name) {
+    this.name = name;
+    this.steps = [];
+  }
+
+  addStep(step) {
+    this.steps.push(step);
+  }
+
+  next(index) {
+    var step = this.steps[0];
+
+    return step.process().then(() => {
+      if (this.steps.length === index+1) {
+        return;
+      }
+
+      return this.next(index+1);
+    });
+  }
+
+  start() {
+    appEvents.emit('show-modal', {
+      src: 'public/app/core/components/wizard/wizard.html',
+      model: this
+    });
+
+    return this.next(0);
+  }
+}
+
+coreModule.service('wizardSrv', WizardSrv);

+ 2 - 1
public/app/core/core.ts

@@ -5,7 +5,6 @@ import "./directives/annotation_tooltip";
 import "./directives/dash_class";
 import "./directives/confirm_click";
 import "./directives/dash_edit_link";
-import "./directives/dash_upload";
 import "./directives/dropdown_typeahead";
 import "./directives/grafana_version_check";
 import "./directives/metric_segment";
@@ -34,6 +33,7 @@ import {layoutSelector} from './components/layout_selector/layout_selector';
 import {switchDirective} from './components/switch';
 import {dashboardSelector} from './components/dashboard_selector';
 import {queryPartEditorDirective} from './components/query_part/query_part_editor';
+import {WizardFlow} from './components/wizard/wizard';
 import 'app/core/controllers/all';
 import 'app/core/services/all';
 import 'app/core/routes/routes';
@@ -58,4 +58,5 @@ export {
   appEvents,
   dashboardSelector,
   queryPartEditorDirective,
+  WizardFlow,
 };

+ 24 - 22
public/app/core/directives/dash_edit_link.js

@@ -6,28 +6,13 @@ function ($, coreModule) {
   'use strict';
 
   var editViewMap = {
-    'settings':    { src: 'public/app/features/dashboard/partials/settings.html', title: "Settings" },
-    'annotations': { src: 'public/app/features/annotations/partials/editor.html', title: "Annotations" },
-    'templating':  { src: 'public/app/features/templating/partials/editor.html', title: "Templating" }
+    'settings':    { src: 'public/app/features/dashboard/partials/settings.html'},
+    'annotations': { src: 'public/app/features/annotations/partials/editor.html'},
+    'templating':  { src: 'public/app/features/templating/partials/editor.html'},
+    'import':      { src: '<dash-import></dash-import>' }
   };
 
-  coreModule.default.directive('dashEditorLink', function($timeout) {
-    return {
-      restrict: 'A',
-      link: function(scope, elem, attrs) {
-        var partial = attrs.dashEditorLink;
-
-        elem.bind('click',function() {
-          $timeout(function() {
-            var editorScope = attrs.editorScope === 'isolated' ? null : scope;
-            scope.appEvent('show-dash-editor', { src: partial, scope: editorScope });
-          });
-        });
-      }
-    };
-  });
-
-  coreModule.default.directive('dashEditorView', function($compile, $location) {
+  coreModule.default.directive('dashEditorView', function($compile, $location, $rootScope) {
     return {
       restrict: 'A',
       link: function(scope, elem) {
@@ -72,8 +57,25 @@ function ($, coreModule) {
             }
           };
 
-          var src = "'" + payload.src + "'";
-          var view = $('<div class="tabbed-view" ng-include="' + src + '"></div>');
+          if (editview === 'import') {
+            var modalScope = $rootScope.$new();
+            modalScope.$on("$destroy", function() {
+              editorScope.dismiss();
+            });
+
+            $rootScope.appEvent('show-modal', {
+              templateHtml: '<dash-import></dash-import>',
+              scope: modalScope,
+              backdrop: 'static'
+            });
+
+            return;
+          }
+
+          var view = payload.src;
+          if (view.indexOf('.html') > 0)  {
+            view = $('<div class="tabbed-view" ng-include="' + "'" + view + "'" + '"></div>');
+          }
 
           elem.append(view);
           $compile(elem.contents())(editorScope);

+ 0 - 46
public/app/core/directives/dash_upload.js

@@ -1,46 +0,0 @@
-define([
-  '../core_module',
-  'app/core/utils/kbn',
-],
-function (coreModule, kbn) {
-  'use strict';
-
-  coreModule.default.directive('dashUpload', function(timer, alertSrv, $location) {
-    return {
-      restrict: 'A',
-      link: function(scope) {
-        function file_selected(evt) {
-          var files = evt.target.files; // FileList object
-          var readerOnload = function() {
-            return function(e) {
-              scope.$apply(function() {
-                try {
-                  window.grafanaImportDashboard = JSON.parse(e.target.result);
-                } catch (err) {
-                  console.log(err);
-                  scope.appEvent('alert-error', ['Import failed', 'JSON -> JS Serialization failed: ' + err.message]);
-                  return;
-                }
-                var title = kbn.slugifyForUrl(window.grafanaImportDashboard.title);
-                window.grafanaImportDashboard.id = null;
-                $location.path('/dashboard-import/' + title);
-              });
-            };
-          };
-          for (var i = 0, f; f = files[i]; i++) {
-            var reader = new FileReader();
-            reader.onload = (readerOnload)(f);
-            reader.readAsText(f);
-          }
-        }
-        // Check for the various File API support.
-        if (window.File && window.FileReader && window.FileList && window.Blob) {
-          // Something
-          document.getElementById('dashupload').addEventListener('change', file_selected, false);
-        } else {
-          alertSrv.set('Oops','Sorry, the HTML5 File APIs are not fully supported in this browser.','error');
-        }
-      }
-    };
-  });
-});

+ 1 - 1
public/app/core/profiler.ts

@@ -1,5 +1,5 @@
 ///<reference path="../headers/common.d.ts" />
-//
+
 import $ from 'jquery';
 import _ from 'lodash';
 import angular from 'angular';

+ 0 - 12
public/app/core/routes/dashboard_loaders.js

@@ -25,18 +25,6 @@ function (coreModule) {
 
   });
 
-  coreModule.default.controller('DashFromImportCtrl', function($scope, $location, alertSrv) {
-    if (!window.grafanaImportDashboard) {
-      alertSrv.set('Not found', 'Cannot reload page with unsaved imported dashboard', 'warning', 7000);
-      $location.path('');
-      return;
-    }
-    $scope.initDashboard({
-      meta: { canShare: false, canStar: false },
-      dashboard: window.grafanaImportDashboard
-    }, $scope);
-  });
-
   coreModule.default.controller('NewDashboardCtrl', function($scope) {
     $scope.initDashboard({
       meta: { canStar: false, canShare: false },

+ 6 - 8
public/app/core/routes/routes.ts

@@ -32,20 +32,18 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
     controller : 'SoloPanelCtrl',
     pageClass: 'page-dashboard',
   })
-  .when('/dashboard-import/:file', {
-    templateUrl: 'public/app/partials/dashboard.html',
-    controller : 'DashFromImportCtrl',
-    reloadOnSearch: false,
-    pageClass: 'page-dashboard',
-  })
   .when('/dashboard/new', {
     templateUrl: 'public/app/partials/dashboard.html',
     controller : 'NewDashboardCtrl',
     reloadOnSearch: false,
     pageClass: 'page-dashboard',
   })
-  .when('/import/dashboard', {
-    templateUrl: 'public/app/features/dashboard/partials/import.html',
+  .when('/dashboards/list', {
+    templateUrl: 'public/app/features/dashboard/partials/dash_list.html',
+    controller : 'DashListCtrl',
+  })
+  .when('/dashboards/migrate', {
+    templateUrl: 'public/app/features/dashboard/partials/migrate.html',
     controller : 'DashboardImportCtrl',
   })
   .when('/datasources', {

+ 5 - 5
public/app/core/services/datasource_srv.js

@@ -84,11 +84,11 @@ function (angular, _, coreModule, config) {
 
       _.each(config.datasources, function(value, key) {
         if (value.meta && value.meta.metrics) {
-          metricSources.push({
-            value: key === config.defaultDatasource ? null : key,
-            name: key,
-            meta: value.meta,
-          });
+          metricSources.push({value: key, name: key, meta: value.meta});
+
+          if (key === config.defaultDatasource) {
+            metricSources.push({value: null, name: 'default', meta: value.meta});
+          }
         }
       });
 

+ 0 - 31
public/app/core/services/util_srv.js

@@ -1,31 +0,0 @@
-define([
-  'angular',
-  '../core_module',
-],
-function (angular, coreModule) {
-  'use strict';
-
-  coreModule.default.service('utilSrv', function($rootScope, $modal, $q) {
-
-    this.init = function() {
-      $rootScope.onAppEvent('show-modal', this.showModal, $rootScope);
-    };
-
-    this.showModal = function(e, options) {
-      var modal = $modal({
-        modalClass: options.modalClass,
-        template: options.src,
-        persist: false,
-        show: false,
-        scope: options.scope,
-        keyboard: false
-      });
-
-      $q.when(modal).then(function(modalEl) {
-        modalEl.modal('show');
-      });
-    };
-
-  });
-
-});

+ 43 - 0
public/app/core/services/util_srv.ts

@@ -0,0 +1,43 @@
+///<reference path="../../headers/common.d.ts" />
+
+import config from 'app/core/config';
+import _ from 'lodash';
+import $ from 'jquery';
+
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+
+export class UtilSrv {
+
+  /** @ngInject */
+  constructor(private $rootScope, private $modal) {
+  }
+
+  init() {
+    appEvents.on('show-modal', this.showModal.bind(this), this.$rootScope);
+  }
+
+  showModal(options) {
+    if (options.model) {
+      options.scope = this.$rootScope.$new();
+      options.scope.model = options.model;
+    }
+
+    var modal = this.$modal({
+      modalClass: options.modalClass,
+      template: options.src,
+      templateHtml: options.templateHtml,
+      persist: false,
+      show: false,
+      scope: options.scope,
+      keyboard: false,
+      backdrop: options.backdrop
+    });
+
+    Promise.resolve(modal).then(function(modalEl) {
+      modalEl.modal('show');
+    });
+  }
+}
+
+coreModule.service('utilSrv', UtilSrv);

+ 5 - 2
public/app/features/dashboard/all.js

@@ -1,5 +1,5 @@
 define([
-  './dashboardCtrl',
+  './dashboard_ctrl',
   './dashboardLoaderSrv',
   './dashnav/dashnav',
   './submenu/submenu',
@@ -14,7 +14,10 @@ define([
   './unsavedChangesSrv',
   './timepicker/timepicker',
   './graphiteImportCtrl',
-  './dynamicDashboardSrv',
   './importCtrl',
   './impression_store',
+  './upload',
+  './import/dash_import',
+  './export/export_modal',
+  './dash_list_ctrl',
 ], function () {});

+ 11 - 0
public/app/features/dashboard/dash_list_ctrl.ts

@@ -0,0 +1,11 @@
+///<reference path="../../headers/common.d.ts" />
+
+import coreModule from 'app/core/core_module';
+
+export class DashListCtrl {
+  /** @ngInject */
+  constructor() {
+  }
+}
+
+coreModule.controller('DashListCtrl', DashListCtrl);

+ 0 - 150
public/app/features/dashboard/dashboardCtrl.js

@@ -1,150 +0,0 @@
-define([
-  'angular',
-  'jquery',
-  'app/core/config',
-  'moment',
-],
-function (angular, $, config, moment) {
-  "use strict";
-
-  var module = angular.module('grafana.controllers');
-
-  module.controller('DashboardCtrl', function(
-      $scope,
-      $rootScope,
-      dashboardKeybindings,
-      timeSrv,
-      templateValuesSrv,
-      dynamicDashboardSrv,
-      dashboardSrv,
-      unsavedChangesSrv,
-      dashboardViewStateSrv,
-      contextSrv,
-      $timeout) {
-
-    $scope.editor = { index: 0 };
-    $scope.panels = config.panels;
-
-    var resizeEventTimeout;
-
-    this.init = function(dashboard) {
-      $scope.resetRow();
-      $scope.registerWindowResizeEvent();
-      $scope.onAppEvent('show-json-editor', $scope.showJsonEditor);
-      $scope.setupDashboard(dashboard);
-    };
-
-    $scope.setupDashboard = function(data) {
-      var dashboard = dashboardSrv.create(data.dashboard, data.meta);
-      dashboardSrv.setCurrent(dashboard);
-
-      // init services
-      timeSrv.init(dashboard);
-
-      // template values service needs to initialize completely before
-      // the rest of the dashboard can load
-      templateValuesSrv.init(dashboard).finally(function() {
-        dynamicDashboardSrv.init(dashboard);
-        unsavedChangesSrv.init(dashboard, $scope);
-
-        $scope.dashboard = dashboard;
-        $scope.dashboardMeta = dashboard.meta;
-        $scope.dashboardViewState = dashboardViewStateSrv.create($scope);
-
-        dashboardKeybindings.shortcuts($scope);
-
-        $scope.updateSubmenuVisibility();
-        $scope.setWindowTitleAndTheme();
-
-        if ($scope.profilingEnabled) {
-          $scope.performance.panels = [];
-          $scope.performance.panelCount = 0;
-          $scope.dashboard.rows.forEach(function(row) {
-            $scope.performance.panelCount += row.panels.length;
-          });
-        }
-
-        $scope.appEvent("dashboard-initialized", $scope.dashboard);
-      }).catch(function(err) {
-        if (err.data && err.data.message) { err.message = err.data.message; }
-        $scope.appEvent("alert-error", ['Dashboard init failed', 'Template variables could not be initialized: ' + err.message]);
-      });
-    };
-
-    $scope.updateSubmenuVisibility = function() {
-      $scope.submenuEnabled = $scope.dashboard.isSubmenuFeaturesEnabled();
-    };
-
-    $scope.setWindowTitleAndTheme = function() {
-      window.document.title = config.window_title_prefix + $scope.dashboard.title;
-    };
-
-    $scope.broadcastRefresh = function() {
-      $rootScope.$broadcast('refresh');
-    };
-
-    $scope.addRow = function(dash, row) {
-      dash.rows.push(row);
-    };
-
-    $scope.addRowDefault = function() {
-      $scope.resetRow();
-      $scope.row.title = 'New row';
-      $scope.addRow($scope.dashboard, $scope.row);
-    };
-
-    $scope.resetRow = function() {
-      $scope.row = {
-        title: '',
-        height: '250px',
-        editable: true,
-      };
-    };
-
-    $scope.showJsonEditor = function(evt, options) {
-      var editScope = $rootScope.$new();
-      editScope.object = options.object;
-      editScope.updateHandler = options.updateHandler;
-      $scope.appEvent('show-dash-editor', { src: 'public/app/partials/edit_json.html', scope: editScope });
-    };
-
-    $scope.onDrop = function(panelId, row, dropTarget) {
-      var info = $scope.dashboard.getPanelInfoById(panelId);
-      if (dropTarget) {
-        var dropInfo = $scope.dashboard.getPanelInfoById(dropTarget.id);
-        dropInfo.row.panels[dropInfo.index] = info.panel;
-        info.row.panels[info.index] = dropTarget;
-        var dragSpan = info.panel.span;
-        info.panel.span = dropTarget.span;
-        dropTarget.span = dragSpan;
-      }
-      else {
-        info.row.panels.splice(info.index, 1);
-        info.panel.span = 12 - $scope.dashboard.rowSpan(row);
-        row.panels.push(info.panel);
-      }
-
-      $rootScope.$broadcast('render');
-    };
-
-    $scope.registerWindowResizeEvent = function() {
-      angular.element(window).bind('resize', function() {
-        $timeout.cancel(resizeEventTimeout);
-        resizeEventTimeout = $timeout(function() { $scope.$broadcast('render'); }, 200);
-      });
-      $scope.$on('$destroy', function() {
-        angular.element(window).unbind('resize');
-      });
-    };
-
-    $scope.timezoneChanged = function() {
-      $rootScope.$broadcast("refresh");
-    };
-
-    $scope.formatDate = function(date) {
-      return moment(date).format('MMM Do YYYY, h:mm:ss a');
-    };
-
-  });
-
-});

+ 2 - 1
public/app/features/dashboard/dashboardSrv.js

@@ -22,7 +22,7 @@ function (angular, $, _, moment) {
 
       this.id = data.id || null;
       this.title = data.title || 'No Title';
-      this.originalTitle = this.title;
+      this.description = data.description;
       this.tags = data.tags || [];
       this.style = data.style || "dark";
       this.timezone = data.timezone || '';
@@ -39,6 +39,7 @@ function (angular, $, _, moment) {
       this.schemaVersion = data.schemaVersion || 0;
       this.version = data.version || 0;
       this.links = data.links || [];
+      this.gnetId = data.gnetId || null;
       this._updateSchema(data);
       this._initMeta(meta);
     }

+ 145 - 0
public/app/features/dashboard/dashboard_ctrl.ts

@@ -0,0 +1,145 @@
+///<reference path="../../headers/common.d.ts" />
+
+import config from 'app/core/config';
+import angular from 'angular';
+import moment from 'moment';
+import _ from 'lodash';
+
+import coreModule from 'app/core/core_module';
+
+export class DashboardCtrl {
+
+  /** @ngInject */
+  constructor(
+    private $scope,
+    private $rootScope,
+    dashboardKeybindings,
+    timeSrv,
+    templateValuesSrv,
+    dashboardSrv,
+    unsavedChangesSrv,
+    dynamicDashboardSrv,
+    dashboardViewStateSrv,
+    contextSrv,
+    $timeout) {
+
+      $scope.editor = { index: 0 };
+      $scope.panels = config.panels;
+
+      var resizeEventTimeout;
+
+      $scope.setupDashboard = function(data) {
+        var dashboard = dashboardSrv.create(data.dashboard, data.meta);
+        dashboardSrv.setCurrent(dashboard);
+
+        // init services
+        timeSrv.init(dashboard);
+
+        // template values service needs to initialize completely before
+        // the rest of the dashboard can load
+        templateValuesSrv.init(dashboard).finally(function() {
+          dynamicDashboardSrv.init(dashboard);
+
+          unsavedChangesSrv.init(dashboard, $scope);
+
+          $scope.dashboard = dashboard;
+          $scope.dashboardMeta = dashboard.meta;
+          $scope.dashboardViewState = dashboardViewStateSrv.create($scope);
+
+          dashboardKeybindings.shortcuts($scope);
+
+          $scope.updateSubmenuVisibility();
+          $scope.setWindowTitleAndTheme();
+
+          $scope.appEvent("dashboard-loaded", $scope.dashboard);
+        }).catch(function(err) {
+          if (err.data && err.data.message) { err.message = err.data.message; }
+          $scope.appEvent("alert-error", ['Dashboard init failed', 'Template variables could not be initialized: ' + err.message]);
+        });
+      };
+
+      $scope.templateVariableUpdated = function() {
+        dynamicDashboardSrv.update($scope.dashboard);
+      };
+
+      $scope.updateSubmenuVisibility = function() {
+        $scope.submenuEnabled = $scope.dashboard.isSubmenuFeaturesEnabled();
+      };
+
+      $scope.setWindowTitleAndTheme = function() {
+        window.document.title = config.window_title_prefix + $scope.dashboard.title;
+      };
+
+      $scope.broadcastRefresh = function() {
+        $rootScope.performance.panelsRendered = 0;
+        $rootScope.$broadcast('refresh');
+      };
+
+      $scope.addRow = function(dash, row) {
+        dash.rows.push(row);
+      };
+
+      $scope.addRowDefault = function() {
+        $scope.resetRow();
+        $scope.row.title = 'New row';
+        $scope.addRow($scope.dashboard, $scope.row);
+      };
+
+      $scope.resetRow = function() {
+        $scope.row = {
+          title: '',
+          height: '250px',
+          editable: true,
+        };
+      };
+
+      $scope.showJsonEditor = function(evt, options) {
+        var editScope = $rootScope.$new();
+        editScope.object = options.object;
+        editScope.updateHandler = options.updateHandler;
+        $scope.appEvent('show-dash-editor', { src: 'public/app/partials/edit_json.html', scope: editScope });
+      };
+
+      $scope.onDrop = function(panelId, row, dropTarget) {
+        var info = $scope.dashboard.getPanelInfoById(panelId);
+        if (dropTarget) {
+          var dropInfo = $scope.dashboard.getPanelInfoById(dropTarget.id);
+          dropInfo.row.panels[dropInfo.index] = info.panel;
+          info.row.panels[info.index] = dropTarget;
+          var dragSpan = info.panel.span;
+          info.panel.span = dropTarget.span;
+          dropTarget.span = dragSpan;
+        } else {
+          info.row.panels.splice(info.index, 1);
+          info.panel.span = 12 - $scope.dashboard.rowSpan(row);
+          row.panels.push(info.panel);
+        }
+
+        $rootScope.$broadcast('render');
+      };
+
+      $scope.registerWindowResizeEvent = function() {
+        angular.element(window).bind('resize', function() {
+          $timeout.cancel(resizeEventTimeout);
+          resizeEventTimeout = $timeout(function() { $scope.$broadcast('render'); }, 200);
+        });
+        $scope.$on('$destroy', function() {
+          angular.element(window).unbind('resize');
+        });
+      };
+
+      $scope.timezoneChanged = function() {
+        $rootScope.$broadcast("refresh");
+      };
+    }
+
+    init(dashboard) {
+      this.$scope.resetRow();
+      this.$scope.registerWindowResizeEvent();
+      this.$scope.onAppEvent('show-json-editor', this.$scope.showJsonEditor);
+      this.$scope.onAppEvent('template-variable-value-updated', this.$scope.templateVariableUpdated);
+      this.$scope.setupDashboard(dashboard);
+    }
+}
+
+coreModule.controller('DashboardCtrl', DashboardCtrl);

+ 10 - 3
public/app/features/dashboard/dashnav/dashnav.html

@@ -26,11 +26,19 @@
 			<li>
 				<a class="pointer" ng-click="shareDashboard(0)">
 					<i class="fa fa-link"></i> Link to Dashboard
+					<div class="dropdown-desc">Share an internal link to the current dashboard. Some configuration options available.</div>
 				</a>
 			</li>
 			<li>
 				<a class="pointer" ng-click="shareDashboard(1)">
-					<i class="icon-gf icon-gf-snapshot"></i>Snapshot sharing
+					<i class="icon-gf icon-gf-snapshot"></i>Snapshot
+					<div class="dropdown-desc">Interactive, publically accessible dashboard. Sensitive data is stripped out.</div>
+				</a>
+			</li>
+      <li>
+				<a class="pointer" ng-click="shareDashboard(2)">
+					<i class="fa fa-cloud-upload"></i>Export
+					<div class="dropdown-desc">Export the dashboard to a JSON file for others and to share on Grafana.net</div>
 				</a>
 			</li>
 		</ul>
@@ -44,8 +52,7 @@
 			<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="openEditView('settings');">Settings</a></li>
 			<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="openEditView('annotations');">Annotations</a></li>
 			<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="openEditView('templating');">Templating</a></li>
-			<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="exportDashboard();">Export</a></li>
-			<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="editJson();">View JSON</a></li>
+			<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="viewJson();">View JSON</a></li>
 			<li ng-if="contextSrv.isEditor && !dashboard.editable"><a class="pointer" ng-click="makeEditable();">Make Editable</a></li>
 			<li ng-if="contextSrv.isEditor"><a class="pointer" ng-click="saveDashboardAs();">Save As...</a></li>
 			<li ng-if="dashboardMeta.canSave"><a class="pointer" ng-click="deleteDashboard();">Delete dashboard</a></li>

+ 7 - 7
public/app/features/dashboard/dashnav/dashnav.ts

@@ -4,15 +4,16 @@ import _ from 'lodash';
 import moment from 'moment';
 import angular from 'angular';
 
+import {DashboardExporter} from '../export/exporter';
+
 export class DashNavCtrl {
 
   /** @ngInject */
-  constructor($scope, $rootScope, alertSrv, $location, playlistSrv, backendSrv, $timeout) {
+  constructor($scope, $rootScope, alertSrv, $location, playlistSrv, backendSrv, $timeout, datasourceSrv) {
 
     $scope.init = function() {
       $scope.onAppEvent('save-dashboard', $scope.saveDashboard);
       $scope.onAppEvent('delete-dashboard', $scope.deleteDashboard);
-      $scope.onAppEvent('export-dashboard', $scope.snapshot);
       $scope.onAppEvent('quick-snapshot', $scope.quickSnapshot);
 
       $scope.showSettingsMenu = $scope.dashboardMeta.canEdit || $scope.contextSrv.isEditor;
@@ -168,11 +169,11 @@ export class DashNavCtrl {
       });
     };
 
-    $scope.exportDashboard = function() {
+    $scope.viewJson = function() {
       var clone = $scope.dashboard.getSaveModelClone();
-      var blob = new Blob([angular.toJson(clone, true)], { type: "application/json;charset=utf-8" });
-      var wnd: any = window;
-      wnd.saveAs(blob, $scope.dashboard.title + '-' + new Date().getTime() + '.json');
+      var html = angular.toJson(clone, true);
+      var uri = "data:application/json," + encodeURIComponent(html);
+      var newWindow = window.open(uri);
     };
 
     $scope.snapshot = function() {
@@ -180,7 +181,6 @@ export class DashNavCtrl {
       $rootScope.$broadcast('refresh');
 
       $timeout(function() {
-        $scope.exportDashboard();
         $scope.dashboard.snapshot = false;
         $scope.appEvent('dashboard-snapshot-cleanup');
       }, 1000);

+ 188 - 0
public/app/features/dashboard/dynamic_dashboard_srv.ts

@@ -0,0 +1,188 @@
+///<reference path="../../headers/common.d.ts" />
+
+import config from 'app/core/config';
+import angular from 'angular';
+import _ from 'lodash';
+
+import coreModule from 'app/core/core_module';
+
+export class DynamicDashboardSrv {
+  iteration: number;
+  dashboard: any;
+
+  constructor() {
+    this.iteration = new Date().getTime();
+  }
+
+  init(dashboard) {
+    if (dashboard.snapshot) { return; }
+    this.process(dashboard, {});
+  }
+
+  update(dashboard) {
+    if (dashboard.snapshot) { return; }
+
+    this.iteration = this.iteration + 1;
+    this.process(dashboard, {});
+  }
+
+  process(dashboard, options) {
+    if (dashboard.templating.list.length === 0) { return; }
+    this.dashboard = dashboard;
+
+    var cleanUpOnly = options.cleanUpOnly;
+
+    var i, j, row, panel;
+    for (i = 0; i < this.dashboard.rows.length; i++) {
+      row = this.dashboard.rows[i];
+      // handle row repeats
+      if (row.repeat) {
+        if (!cleanUpOnly) {
+          this.repeatRow(row, i);
+        }
+      } else if (row.repeatRowId && row.repeatIteration !== this.iteration) {
+        // clean up old left overs
+        this.dashboard.rows.splice(i, 1);
+        i = i - 1;
+        continue;
+      }
+
+      // repeat panels
+      for (j = 0; j < row.panels.length; j++) {
+        panel = row.panels[j];
+        if (panel.repeat) {
+          if (!cleanUpOnly) {
+            this.repeatPanel(panel, row);
+          }
+        } else if (panel.repeatPanelId && panel.repeatIteration !== this.iteration) {
+          // clean up old left overs
+          row.panels = _.without(row.panels, panel);
+          j = j - 1;
+        } else if (!_.isEmpty(panel.scopedVars) && panel.repeatIteration !== this.iteration) {
+          panel.scopedVars = {};
+        }
+      }
+    }
+  }
+
+  // returns a new row clone or reuses a clone from previous iteration
+  getRowClone(sourceRow, repeatIndex, sourceRowIndex) {
+    if (repeatIndex === 0) {
+      return sourceRow;
+    }
+
+    var i, panel, row, copy;
+    var sourceRowId = sourceRowIndex + 1;
+
+    // look for row to reuse
+    for (i = 0; i < this.dashboard.rows.length; i++) {
+      row = this.dashboard.rows[i];
+      if (row.repeatRowId === sourceRowId && row.repeatIteration !== this.iteration) {
+        copy = row;
+        break;
+      }
+    }
+
+    if (!copy) {
+      copy = angular.copy(sourceRow);
+      this.dashboard.rows.splice(sourceRowIndex + repeatIndex, 0, copy);
+
+      // set new panel ids
+      for (i = 0; i < copy.panels.length; i++) {
+        panel = copy.panels[i];
+        panel.id = this.dashboard.getNextPanelId();
+      }
+    }
+
+    copy.repeat = null;
+    copy.repeatRowId = sourceRowId;
+    copy.repeatIteration = this.iteration;
+    return copy;
+  }
+
+  // returns a new row clone or reuses a clone from previous iteration
+  repeatRow(row, rowIndex) {
+    var variables = this.dashboard.templating.list;
+    var variable = _.findWhere(variables, {name: row.repeat});
+    if (!variable) {
+      return;
+    }
+
+    var selected, copy, i, panel;
+    if (variable.current.text === 'All') {
+      selected = variable.options.slice(1, variable.options.length);
+    } else {
+      selected = _.filter(variable.options, {selected: true});
+    }
+
+    _.each(selected, (option, index) => {
+      copy = this.getRowClone(row, index, rowIndex);
+      copy.scopedVars = {};
+      copy.scopedVars[variable.name] = option;
+
+      for (i = 0; i < copy.panels.length; i++) {
+        panel = copy.panels[i];
+        panel.scopedVars = {};
+        panel.scopedVars[variable.name] = option;
+        panel.repeatIteration = this.iteration;
+      }
+    });
+  }
+
+  getPanelClone(sourcePanel, row, index) {
+    // if first clone return source
+    if (index === 0) {
+      return sourcePanel;
+    }
+
+    var i, tmpId, panel, clone;
+
+    // first try finding an existing clone to use
+    for (i = 0; i < row.panels.length; i++) {
+      panel = row.panels[i];
+      if (panel.repeatIteration !== this.iteration && panel.repeatPanelId === sourcePanel.id) {
+        clone = panel;
+        break;
+      }
+    }
+
+    if (!clone) {
+      clone = { id: this.dashboard.getNextPanelId() };
+      row.panels.push(clone);
+    }
+
+    // save id
+    tmpId = clone.id;
+    // copy properties from source
+    angular.copy(sourcePanel, clone);
+    // restore id
+    clone.id = tmpId;
+    clone.repeatIteration = this.iteration;
+    clone.repeatPanelId = sourcePanel.id;
+    clone.repeat = null;
+    return clone;
+  }
+
+  repeatPanel(panel, row) {
+    var variables = this.dashboard.templating.list;
+    var variable = _.findWhere(variables, {name: panel.repeat});
+    if (!variable) { return; }
+
+    var selected;
+    if (variable.current.text === 'All') {
+      selected = variable.options.slice(1, variable.options.length);
+    } else {
+      selected = _.filter(variable.options, {selected: true});
+    }
+
+    _.each(selected, (option, index) => {
+      var copy = this.getPanelClone(panel, row, index);
+      copy.span = Math.max(12 / selected.length, panel.minSpan);
+      copy.scopedVars = copy.scopedVars || {};
+      copy.scopedVars[variable.name] = option;
+    });
+  }
+}
+
+coreModule.service('dynamicDashboardSrv', DynamicDashboardSrv);
+

+ 29 - 0
public/app/features/dashboard/export/export_modal.html

@@ -0,0 +1,29 @@
+
+<!-- <p> -->
+<!-- 	Exporting will export a cleaned sharable dashboard that can be imported -->
+<!-- 	into another Grafana instance. -->
+<!-- </p> -->
+
+<div class="share-modal-header">
+	<div class="share-modal-big-icon">
+		<i class="fa fa-cloud-upload"></i>
+	</div>
+	<div>
+		<p class="share-modal-info-text">
+			Export the dashboard to a JSON file. The exporter will templatize the
+			dashboard's data sources to make it easy for other's to to import and reuse.
+			You can share dashboards on <a class="external-link" href="https://grafana.net">Grafana.net</a>
+		</p>
+
+		<div class="gf-form-button-row">
+			<button type="button" class="btn gf-form-btn width-10 btn-success" ng-click="ctrl.save()">
+				<i class="fa fa-save"></i> Save to file
+			</button>
+			<button type="button" class="btn gf-form-btn width-10 btn-secondary" ng-click="ctrl.saveJson()">
+				<i class="fa fa-file-text-o"></i> View JSON
+			</button>
+			<a class="btn btn-link" ng-click="dismiss()">Cancel</a>
+		</div>
+
+	</div>
+</div>

+ 53 - 0
public/app/features/dashboard/export/export_modal.ts

@@ -0,0 +1,53 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import kbn from 'app/core/utils/kbn';
+import angular from 'angular';
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+import config from 'app/core/config';
+import _ from 'lodash';
+
+import {DashboardExporter} from './exporter';
+
+export class DashExportCtrl {
+  dash: any;
+  exporter: DashboardExporter;
+
+  /** @ngInject */
+  constructor(private backendSrv, dashboardSrv, datasourceSrv, $scope) {
+    this.exporter = new DashboardExporter(datasourceSrv);
+
+    var current = dashboardSrv.getCurrent().getSaveModelClone();
+
+    this.exporter.makeExportable(current).then(dash => {
+      $scope.$apply(() => {
+        this.dash = dash;
+      });
+    });
+  }
+
+  save() {
+    var blob = new Blob([angular.toJson(this.dash, true)], { type: "application/json;charset=utf-8" });
+    var wnd: any = window;
+    wnd.saveAs(blob, this.dash.title + '-' + new Date().getTime() + '.json');
+  }
+
+  saveJson() {
+    var html = angular.toJson(this.dash, true);
+    var uri = "data:application/json," + encodeURIComponent(html);
+    var newWindow = window.open(uri);
+  }
+
+}
+
+export function dashExportDirective() {
+  return {
+    restrict: 'E',
+    templateUrl: 'public/app/features/dashboard/export/export_modal.html',
+    controller: DashExportCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+  };
+}
+
+coreModule.directive('dashExportModal', dashExportDirective);

+ 135 - 0
public/app/features/dashboard/export/exporter.ts

@@ -0,0 +1,135 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import config from 'app/core/config';
+import angular from 'angular';
+import _ from 'lodash';
+
+import {DynamicDashboardSrv} from '../dynamic_dashboard_srv';
+
+export class DashboardExporter {
+
+  constructor(private datasourceSrv) {
+  }
+
+  makeExportable(dash) {
+    var dynSrv = new DynamicDashboardSrv();
+    dynSrv.process(dash, {cleanUpOnly: true});
+
+    dash.id = null;
+
+    var inputs = [];
+    var requires = {};
+    var datasources = {};
+    var promises = [];
+
+    var templateizeDatasourceUsage = obj => {
+      promises.push(this.datasourceSrv.get(obj.datasource).then(ds => {
+        var refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase();
+        datasources[refName] = {
+          name: refName,
+          label: ds.name,
+          description: '',
+          type: 'datasource',
+          pluginId: ds.meta.id,
+          pluginName: ds.meta.name,
+        };
+        obj.datasource = '${' + refName  +'}';
+
+        requires['datasource' + ds.meta.id] = {
+          type: 'datasource',
+          id: ds.meta.id,
+          name: ds.meta.name,
+          version: ds.meta.info.version || "1.0.0",
+        };
+      }));
+    };
+
+    // check up panel data sources
+    for (let row of dash.rows) {
+      _.each(row.panels, (panel) => {
+        if (panel.datasource !== undefined) {
+          templateizeDatasourceUsage(panel);
+        }
+
+        var panelDef = config.panels[panel.type];
+        if (panelDef) {
+          requires['panel' + panelDef.id] = {
+            type: 'panel',
+            id: panelDef.id,
+            name: panelDef.name,
+            version: panelDef.info.version,
+          };
+        }
+      });
+    }
+
+    // templatize template vars
+    for (let variable of dash.templating.list) {
+      if (variable.type === 'query') {
+        templateizeDatasourceUsage(variable);
+        variable.options = [];
+        variable.current = {};
+        variable.refresh = 1;
+      }
+    }
+
+    // templatize annotations vars
+    for (let annotationDef of dash.annotations.list) {
+      templateizeDatasourceUsage(annotationDef);
+    }
+
+    // add grafana version
+    requires['grafana'] = {
+      type: 'grafana',
+      id: 'grafana',
+      name: 'Grafana',
+      version: config.buildInfo.version
+    };
+
+    return Promise.all(promises).then(() => {
+      _.each(datasources, (value, key) => {
+        inputs.push(value);
+      });
+
+      // templatize constants
+      for (let variable of dash.templating.list) {
+        if (variable.type === 'constant') {
+          var refName = 'VAR_' + variable.name.replace(' ', '_').toUpperCase();
+          inputs.push({
+            name: refName,
+            type: 'constant',
+            label: variable.label || variable.name,
+            value: variable.current.value,
+            description: '',
+          });
+          // update current and option
+          variable.query = '${' + refName + '}';
+          variable.options[0] = variable.current = {
+            value: variable.query,
+            text: variable.query,
+          };
+        }
+      }
+
+      requires = _.map(requires, req =>  {
+        return req;
+      });
+
+      // make inputs and requires a top thing
+      var newObj = {};
+      newObj["__inputs"] = inputs;
+      newObj["__requires"] = requires;
+
+      _.defaults(newObj, dash);
+
+      return newObj;
+    }).catch(err => {
+      console.log('Export failed:', err);
+      return {
+        error: err
+      };
+    });
+  }
+
+}
+

+ 130 - 0
public/app/features/dashboard/import/dash_import.html

@@ -0,0 +1,130 @@
+<div class="modal-body">
+
+	<div class="modal-header">
+		<h2 class="modal-header-title">
+			<i class="fa fa-upload"></i>
+			<span class="p-l-1">Import Dashboard</span>
+		</h2>
+
+		<a class="modal-header-close" ng-click="dismiss();">
+			<i class="fa fa-remove"></i>
+		</a>
+	</div>
+
+	<div class="modal-content" ng-cloak>
+		<div ng-if="ctrl.step === 1">
+
+			<form class="gf-form-group">
+				<dash-upload on-upload="ctrl.onUpload(dash)"></dash-upload>
+			</form>
+
+			<h5 class="section-heading">Grafana.net Dashboard</h5>
+
+      <div class="gf-form-group">
+				<div class="gf-form">
+					<input type="text" class="gf-form-input" ng-model="ctrl.gnetUrl" placeholder="Paste Grafana.net dashboard url or id" ng-blur="ctrl.checkGnetDashboard()"></textarea>
+				</div>
+        <div class="gf-form" ng-if="ctrl.gnetError">
+          <label class="gf-form-label text-warning">
+            <i class="fa fa-warning"></i>
+            {{ctrl.gnetError}}
+          </label>
+        </div>
+      </div>
+
+      <h5 class="section-heading">Or paste JSON</h5>
+
+			<div class="gf-form-group">
+				<div class="gf-form">
+					<textarea rows="7" data-share-panel-url="" class="gf-form-input" ng-ctrl="ctrl.jsonText"></textarea>
+				</div>
+				<button type="button" class="btn btn-secondary" ng-click="ctrl.loadJsonText()">
+					<i class="fa fa-paste"></i>
+					Load
+				</button>
+				<span ng-if="ctrl.parseError" class="text-error p-l-1">
+					<i class="fa fa-warning"></i>
+					{{ctrl.parseError}}
+				</span>
+			</div>
+		</div>
+
+    <div ng-if="ctrl.step === 2">
+			<div class="gf-form-group" ng-if="ctrl.dash.gnetId">
+        <h3 class="section-heading">
+          Importing Dashboard from
+          <a href="https://grafana.net/dashboards/{{ctrl.dash.gnetId}}" class="external-link" target="_blank">Grafana.net</a>
+        </h3>
+
+        <div class="gf-form">
+          <label class="gf-form-label width-15">Published by</label>
+          <label class="gf-form-label width-15">{{ctrl.gnetInfo.orgName}}</label>
+        </div>
+        <div class="gf-form">
+          <label class="gf-form-label width-15">Updated on</label>
+          <label class="gf-form-label width-15">{{ctrl.gnetInfo.updatedAt | date : 'yyyy-MM-dd HH:mm:ss'}}</label>
+        </div>
+      </div>
+
+      <h3 class="section-heading">
+        Options
+      </h3>
+
+      <div class="gf-form-group">
+        <div class="gf-form-inline">
+          <div class="gf-form gf-form--grow">
+            <label class="gf-form-label width-15">Name</label>
+            <input type="text" class="gf-form-input" ng-model="ctrl.dash.title" give-focus="true" ng-change="ctrl.titleChanged()" ng-class="{'validation-error': ctrl.nameExists}">
+            <label class="gf-form-label text-success" ng-if="!ctrl.nameExists">
+              <i class="fa fa-check"></i>
+            </label>
+          </div>
+        </div>
+
+        <div class="gf-form-inline" ng-if="ctrl.nameExists">
+          <div class="gf-form offset-width-15 gf-form--grow">
+            <label class="gf-form-label text-warning gf-form-label--grow">
+              <i class="fa fa-warning"></i>
+              A Dashboard with the same name already exists
+            </label>
+          </div>
+        </div>
+
+        <div ng-repeat="input in ctrl.inputs">
+          <div class="gf-form">
+            <label class="gf-form-label width-15">
+              {{input.label}}
+              <info-popover mode="right-normal">
+                {{input.info}}
+              </info-popover>
+            </label>
+            <!-- Data source input -->
+            <div class="gf-form-select-wrapper" style="width: 100%" ng-if="input.type === 'datasource'">
+              <select class="gf-form-input" ng-model="input.value" ng-options="v.value as v.text for v in input.options" ng-change="ctrl.inputValueChanged()">
+                <option value="" ng-hide="input.value">{{input.info}}</option>
+              </select>
+            </div>
+            <!-- Constant input -->
+            <input ng-if="input.type === 'constant'" type="text" class="gf-form-input" ng-model="input.value" placeholder="{{input.default}}" ng-change="ctrl.inputValueChanged()">
+            <label class="gf-form-label text-success" ng-show="input.value">
+              <i class="fa fa-check"></i>
+            </label>
+          </div>
+        </div>
+      </div>
+
+      <div class="gf-form-button-row">
+        <button type="button" class="btn gf-form-btn btn-success width-10" ng-click="ctrl.saveDashboard()" ng-hide="ctrl.nameExists" ng-disabled="!ctrl.inputsValid">
+          <i class="fa fa-save"></i> Save &amp; Open
+        </button>
+        <button type="button" class="btn gf-form-btn btn-danger width-10" ng-click="ctrl.saveDashboard()" ng-show="ctrl.nameExists" ng-disabled="!ctrl.inputsValid">
+          <i class="fa fa-save"></i> Overwrite &amp; Open
+        </button>
+        <a class="btn btn-link" ng-click="dismiss()">Cancel</a>
+        <a class="btn btn-link" ng-click="ctrl.back()">Back</a>
+      </div>
+
+    </div>
+  </div>
+</div>
+

+ 180 - 0
public/app/features/dashboard/import/dash_import.ts

@@ -0,0 +1,180 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import kbn from 'app/core/utils/kbn';
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+import config from 'app/core/config';
+import _ from 'lodash';
+
+export class DashImportCtrl {
+  step: number;
+  jsonText: string;
+  parseError: string;
+  nameExists: boolean;
+  dash: any;
+  inputs: any[];
+  inputsValid: boolean;
+  gnetUrl: string;
+  gnetError: string;
+  gnetInfo: any;
+
+  /** @ngInject */
+  constructor(private backendSrv, private $location, private $scope, private $routeParams) {
+    this.step = 1;
+    this.nameExists = false;
+
+    // check gnetId in url
+    if ($routeParams.gnetId)  {
+      this.gnetUrl = $routeParams.gnetId ;
+      this.checkGnetDashboard();
+    }
+  }
+
+  onUpload(dash) {
+    this.dash = dash;
+    this.dash.id = null;
+    this.step = 2;
+    this.inputs = [];
+
+    if (this.dash.__inputs) {
+      for (let input of this.dash.__inputs) {
+        var inputModel = {
+          name: input.name,
+          label: input.label,
+          info: input.description,
+          value: input.value,
+          type: input.type,
+          pluginId: input.pluginId,
+          options: []
+        };
+
+        if (input.type === 'datasource') {
+          this.setDatasourceOptions(input, inputModel);
+        } else if (!inputModel.info) {
+          inputModel.info = 'Specify a string constant';
+        }
+
+        this.inputs.push(inputModel);
+      }
+    }
+
+    this.inputsValid = this.inputs.length === 0;
+    this.titleChanged();
+  }
+
+  setDatasourceOptions(input, inputModel) {
+    var sources = _.filter(config.datasources, val => {
+      return val.type === input.pluginId;
+    });
+
+    if (sources.length === 0) {
+      inputModel.info = "No data sources of type " + input.pluginName + " found";
+    } else if (inputModel.description) {
+      inputModel.info = inputModel.description;
+    } else {
+      inputModel.info = "Select a " + input.pluginName + " data source";
+    }
+
+    inputModel.options = sources.map(val => {
+      return {text: val.name, value: val.name};
+    });
+  }
+
+  inputValueChanged() {
+    this.inputsValid = true;
+    for (let input of this.inputs) {
+      if (!input.value) {
+        this.inputsValid = false;
+      }
+    }
+  }
+
+  titleChanged() {
+    this.backendSrv.search({query: this.dash.title}).then(res => {
+      this.nameExists = false;
+      for (let hit of res) {
+        if (this.dash.title === hit.title) {
+          this.nameExists = true;
+          break;
+        }
+      }
+    });
+  }
+
+  saveDashboard() {
+    var inputs = this.inputs.map(input => {
+      return {
+        name: input.name,
+        type: input.type,
+        pluginId: input.pluginId,
+        value: input.value
+      };
+    });
+
+    return this.backendSrv.post('api/dashboards/import', {
+      dashboard: this.dash,
+      overwrite: true,
+      inputs: inputs
+    }).then(res => {
+      this.$location.url('dashboard/' + res.importedUri);
+      this.$scope.dismiss();
+    });
+  }
+
+  loadJsonText() {
+    try {
+      this.parseError = '';
+      var dash = JSON.parse(this.jsonText);
+      this.onUpload(dash);
+    } catch (err) {
+      console.log(err);
+      this.parseError = err.message;
+      return;
+    }
+  }
+
+  checkGnetDashboard() {
+    this.gnetError = '';
+
+    var match = /(^\d+$)|dashboards\/(\d+)/.exec(this.gnetUrl);
+    var dashboardId;
+
+    if (match && match[1]) {
+      dashboardId = match[1];
+    } else if (match && match[2]) {
+      dashboardId = match[2];
+    } else {
+      this.gnetError = 'Could not find dashboard';
+    }
+
+    return this.backendSrv.get('api/gnet/dashboards/' + dashboardId).then(res => {
+      this.gnetInfo = res;
+      // store reference to grafana.net
+      res.json.gnetId = res.id;
+      this.onUpload(res.json);
+    }).catch(err => {
+      err.isHandled = true;
+      this.gnetError = err.data.message || err;
+    });
+  }
+
+  back() {
+    this.gnetUrl = '';
+    this.step = 1;
+    this.gnetError = '';
+    this.gnetInfo = '';
+  }
+
+}
+
+export function dashImportDirective() {
+  return {
+    restrict: 'E',
+    templateUrl: 'public/app/features/dashboard/import/dash_import.html',
+    controller: DashImportCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+  };
+}
+
+coreModule.directive('dashImport', dashImportDirective);

+ 0 - 4
public/app/features/dashboard/keybindings.js

@@ -68,10 +68,6 @@ function(angular, $) {
         scope.appEvent('shift-time-forward', evt);
       }, { inputDisabled: true });
 
-      keyboardManager.bind('ctrl+e', function(evt) {
-        scope.appEvent('export-dashboard', evt);
-      }, { inputDisabled: true });
-
       keyboardManager.bind('ctrl+i', function(evt) {
         scope.appEvent('quick-snapshot', evt);
       }, { inputDisabled: true });

+ 10 - 0
public/app/features/dashboard/partials/dash_list.html

@@ -0,0 +1,10 @@
+<navbar title="Dashboards" title-url="dashboards" icon="icon-gf icon-gf-dashboard">
+</navbar>
+
+<div class="page-container">
+  <div class="page-header">
+		<h1>Dashboards</h1>
+	</div>
+</div>
+
+

+ 3 - 11
public/app/features/dashboard/partials/import.html → public/app/features/dashboard/partials/migrate.html

@@ -1,23 +1,15 @@
-<navbar title="Import" title-url="import/dashboard" icon="fa fa-download">
+<navbar title="Migrate" title-url="dashboards/migrate" icon="fa fa-download">
 </navbar>
 
 <div class="page-container">
 	<div class="page-header">
 		<h1>
-			Import file
-			<em style="font-size: 14px;padding-left: 10px;"> <i class="fa fa-info-circle"></i> Load dashboard from local .json file</em>
+			Migrate dashboards
 		</h1>
 	</div>
 
-	<div class="gf-form-group">
-			<form class="gf-form">
-				<input type="file" id="dashupload" dash-upload/><br>
-			</form>
-	</div>
-
 	<h5 class="section-heading">
-		Migrate dashboards
-		<em style="font-size: 14px;padding-left: 10px;"><i class="fa fa-info-circle"></i> Import dashboards from Elasticsearch or InfluxDB</em>
+		Import dashboards from Elasticsearch or InfluxDB
 	</h5>
 
 	<div class="gf-form-inline gf-form-group">

+ 9 - 5
public/app/features/dashboard/partials/settings.html

@@ -22,10 +22,14 @@
 		<div class="gf-form-group section">
       <h5 class="section-heading">Details</h5>
 			<div class="gf-form">
-				<label class="gf-form-label width-7">Title</label>
-				<input type="text" class="gf-form-input width-25" ng-model='dashboard.title'></input>
+				<label class="gf-form-label width-7">Name</label>
+				<input type="text" class="gf-form-input width-30" ng-model='dashboard.title'></input>
 			</div>
-			<div class="gf-form">
+      <div class="gf-form">
+				<label class="gf-form-label width-7">Description</label>
+				<input type="text" class="gf-form-input width-30" ng-model='dashboard.description'></input>
+			</div>
+      <div class="gf-form">
 				<label class="gf-form-label width-7">
           Tags
           <info-popover mode="right-normal">Press enter to add a tag</info-popover>
@@ -107,7 +111,7 @@
 		<div class="gf-form-group">
 			<div class="gf-form">
 				<span class="gf-form-label width-10">Last updated at:</span>
-				<span class="gf-form-label width-18">{{formatDate(dashboardMeta.updated)}}</span>
+				<span class="gf-form-label width-18">{{dashboard.formatDate(dashboardMeta.updated)}}</span>
 			</div>
 			<div class="gf-form">
 				<span class="gf-form-label width-10">Last updated by:</span>
@@ -115,7 +119,7 @@
 			</div>
 			<div class="gf-form">
 				<span class="gf-form-label width-10">Created at:</span>
-				<span class="gf-form-label width-18">{{formatDate(dashboardMeta.created)}}&nbsp;</span>
+				<span class="gf-form-label width-18">{{dashboard.formatDate(dashboardMeta.created)}}&nbsp;</span>
 			</div>
 			<div class="gf-form">
 				<span class="gf-form-label width-10">Created by:</span>

+ 101 - 87
public/app/features/dashboard/partials/shareModal.html

@@ -25,28 +25,33 @@
 </div>
 
 <script type="text/ng-template" id="shareEmbed.html">
-	<div class="share-modal-big-icon">
-		<i class="fa fa-code"></i>
-	</div>
-
-	<div class="share-snapshot-header">
-		<p class="share-snapshot-info-text">
-			The html code below can be pasted and included in another web page. Unless anonymous access
-			is enabled the user viewing that page need to be signed into grafana for the graph to load.
-		</p>
-	</div>
+	<div class="share-modal-header">
+		<div class="share-modal-big-icon">
+			<i class="fa fa-code"></i>
+		</div>
+		<div class="share-modal-content">
+			<p class="share-modal-info-text">
+				The html code below can be pasted and included in another web page. Unless anonymous access
+				is enabled the user viewing that page need to be signed into grafana for the graph to load.
+			</p>
 
-	<div ng-include src="'shareLinkOptions.html'"></div>
+			<div ng-include src="'shareLinkOptions.html'"></div>
 
-	<div class="gf-form-group section">
-		<div class="gf-form width-30">
-			<textarea rows="5" data-share-panel-url class="gf-form-input width-30" ng-model='iframeHtml'></textarea>
+			<div class="gf-form-group gf-form--grow">
+				<div class="gf-form">
+					<textarea rows="5" data-share-panel-url class="gf-form-input" ng-model='iframeHtml'></textarea>
+				</div>
+			</div>
 		</div>
 	</div>
 </script>
 
+<script type="text/ng-template" id="shareExport.html">
+	<dash-export-modal></dash-export-modal>
+</script>
+
 <script type="text/ng-template" id="shareLinkOptions.html">
-	<div class="gf-form-group section">
+	<div class="gf-form-group">
 		<gf-form-switch class="gf-form"
 			label="Current time range" label-class="width-12" switch-class="max-width-6"
 			checked="options.forCurrent" on-change="buildUrl()">
@@ -65,91 +70,100 @@
 </script>
 
 <script type="text/ng-template" id="shareLink.html">
-	<div class="share-modal-big-icon">
-		<i class="fa fa-link"></i>
-	</div>
-
-	<div ng-include src="'shareLinkOptions.html'"></div>
-	<div>
-		<div class="gf-form-group section">
-			<div class="gf-form-inline">
-				<div class="gf-form width-30">
-					<input type="text" data-share-panel-url class="gf-form-input" ng-model="shareUrl"></input>
-				</div>
-				<div class="gf-form pull-right">
-					<button class="btn btn-inverse pull-right" data-clipboard-text="{{shareUrl}}" clipboard-button><i class="fa fa-clipboard"></i> Copy</button>
+	<div class="share-modal-header">
+		<div class="share-modal-big-icon">
+			<i class="fa fa-link"></i>
+		</div>
+		<div class="share-modal-content">
+			<p class="share-modal-info-text">
+				Create a direct link to this dashboard or panel, customized with the options below.
+			</p>
+			<div ng-include src="'shareLinkOptions.html'"></div>
+			<div>
+				<div class="gf-form-group">
+					<div class="gf-form-inline">
+						<div class="gf-form gf-form--grow">
+							<input type="text" data-share-panel-url class="gf-form-input" ng-model="shareUrl"></input>
+						</div>
+						<div class="gf-form">
+							<button class="btn btn-inverse" data-clipboard-text="{{shareUrl}}" clipboard-button><i class="fa fa-clipboard"></i> Copy</button>
+						</div>
+					</div>
 				</div>
 			</div>
+			<div class="gf-form" ng-show="modeSharePanel">
+				<a href="{{imageUrl}}" target="_blank"><i class="fa fa-camera"></i> Direct link rendered image</a>
+			</div>
 		</div>
-	</div>
-	<div class="gf-form section" ng-show="modeSharePanel">
-		<a href="{{imageUrl}}" target="_blank"><i class="fa fa-camera"></i> Direct link rendered image</a>
-	</div>
 </script>
 
 <script type="text/ng-template" id="shareSnapshot.html">
 	<div class="ng-cloak" ng-cloak ng-controller="ShareSnapshotCtrl" ng-init="init()">
-		<div class="share-modal-big-icon">
-			<i ng-if="loading" class="fa fa-spinner fa-spin"></i>
-			<i ng-if="!loading" class="icon-gf icon-gf-snapshot"></i>
-		</div>
-
-		<div class="share-snapshot-header" ng-if="step === 1">
-			<p class="share-snapshot-info-text">
-				A snapshot is an instant way to share an interactive dashboard publicly.
-				When created, we <strong>strip sensitive data</strong> like queries (metric, template and annotation) and panel links,
-				leaving only the visible metric data and series names embedded into your dashboard.
-			</p>
-			<p class="share-snapshot-info-text">
-				Keep in mind, your <strong>snapshot can be viewed by anyone</strong> that has the link and can reach the URL.
-				Share wisely.
-			</p>
-		</div>
-
-		<div class="share-snapshot-header" ng-if="step === 3">
-			<p class="share-snapshot-info-text">
-				The snapshot has now been deleted. If it you have already accessed it once, It might take up to an hour before it is removed from
-				browser caches or CDN caches.
-			</p>
-		</div>
-
-		<div class="gf-form-group share-modal-options">
-			<div class="gf-form" ng-if="step === 1">
-				<span class="gf-form-label width-12">Snapshot name</span>
-				<input type="text" ng-model="snapshot.name" class="gf-form-input max-width-15" >
+		<div class="share-modal-header">
+			<div class="share-modal-big-icon">
+				<i ng-if="loading" class="fa fa-spinner fa-spin"></i>
+				<i ng-if="!loading" class="icon-gf icon-gf-snapshot"></i>
 			</div>
-			<div class="gf-form" ng-if="step === 1">
-				<span class="gf-form-label width-12">Expire</span>
-				<div class="gf-form-select-wrapper max-width-15">
-					<select class="gf-form-input" ng-model="snapshot.expires" ng-options="f.value as f.text for f in expireOptions"></select>
+      <div class="share-modal-content">
+        <div ng-if="step === 1">
+          <p class="share-modal-info-text">
+            A snapshot is an instant way to share an interactive dashboard publicly.
+            When created, we <strong>strip sensitive data</strong> like queries (metric, template and annotation) and panel links,
+            leaving only the visible metric data and series names embedded into your dashboard.
+          </p>
+          <p class="share-modal-info-text">
+            Keep in mind, your <strong>snapshot can be viewed by anyone</strong> that has the link and can reach the URL.
+            Share wisely.
+          </p>
+        </div>
+
+				<div class="share-modal-header" ng-if="step === 3">
+					<p class="share-modal-info-text">
+						The snapshot has now been deleted. If it you have already accessed it once, It might take up to an hour before it is removed from
+						browser caches or CDN caches.
+					</p>
 				</div>
-			</div>
 
-			<div class="gf-form" ng-if="step === 2" style="margin-top: 40px">
-				<div class="gf-form-row">
-					<a href="{{snapshotUrl}}" class="large share-snapshot-link" target="_blank">
-						<i class="fa fa-external-link-square"></i>
-						{{snapshotUrl}}
-					</a>
-					<br>
-					<button class="btn btn-inverse btn-large" data-clipboard-text="{{snapshotUrl}}" clipboard-button><i class="fa fa-clipboard"></i> Copy Link</button>
+				<div class="gf-form-group share-modal-options">
+					<div class="gf-form" ng-if="step === 1">
+						<span class="gf-form-label width-12">Snapshot name</span>
+						<input type="text" ng-model="snapshot.name" class="gf-form-input max-width-15" >
+					</div>
+					<div class="gf-form" ng-if="step === 1">
+						<span class="gf-form-label width-12">Expire</span>
+						<div class="gf-form-select-wrapper max-width-15">
+							<select class="gf-form-input" ng-model="snapshot.expires" ng-options="f.value as f.text for f in expireOptions"></select>
+						</div>
+					</div>
+
+					<div class="gf-form" ng-if="step === 2" style="margin-top: 40px">
+						<div class="gf-form-row">
+							<a href="{{snapshotUrl}}" class="large share-modal-link" target="_blank">
+								<i class="fa fa-external-link-square"></i>
+								{{snapshotUrl}}
+							</a>
+							<br>
+							<button class="btn btn-inverse" data-clipboard-text="{{snapshotUrl}}" clipboard-button><i class="fa fa-clipboard"></i> Copy Link</button>
+						</div>
+					</div>
 				</div>
-			</div>
-		</div>
 
-		<div ng-if="step === 1" class="gf-form-buttons-row">
-			<button class="btn btn-success btn-large" ng-click="createSnapshot()" ng-disabled="loading">
-				<i class="fa fa-save"></i>
-				Local Snapshot
-			</button>
-			<button class="btn btn-primary btn-large" ng-if="externalEnabled" ng-click="createSnapshot(true)" ng-disabled="loading">
-				<i class="fa fa-cloud-upload"></i>
-				{{sharingButtonText}}
-			</button>
-		</div>
+				<div ng-if="step === 1" class="gf-form-button-row">
+					<button class="btn gf-form-btn width-10 btn-success" ng-click="createSnapshot()" ng-disabled="loading">
+						<i class="fa fa-save"></i>
+						Local Snapshot
+					</button>
+					<button class="btn gf-form-btn width-16 btn-secondary" ng-if="externalEnabled" ng-click="createSnapshot(true)" ng-disabled="loading">
+						<i class="fa fa-cloud-upload"></i>
+						{{sharingButtonText}}
+					</button>
+					<a class="btn btn-link" ng-click="dismiss()">Cancel</a>
+				</div>
 
-		<div class="pull-right" ng-if="step === 2" style="padding: 5px">
-			Did you make a mistake? <a class="pointer" ng-click="deleteSnapshot()" target="_blank">delete snapshot.</a>
+				<div class="pull-right" ng-if="step === 2" style="padding: 5px">
+					Did you make a mistake? <a class="pointer" ng-click="deleteSnapshot()" target="_blank">delete snapshot.</a>
+				</div>
+			</div>
 		</div>
 	</div>
 

+ 6 - 2
public/app/features/dashboard/shareModalCtrl.js

@@ -22,11 +22,15 @@ function (angular, _, require, config) {
         $scope.modalTitle = 'Share Panel';
         $scope.tabs.push({title: 'Embed', src: 'shareEmbed.html'});
       } else {
-        $scope.modalTitle = 'Share Dashboard';
+        $scope.modalTitle = 'Share';
       }
 
       if (!$scope.dashboard.meta.isSnapshot) {
-        $scope.tabs.push({title: 'Snapshot sharing', src: 'shareSnapshot.html'});
+        $scope.tabs.push({title: 'Snapshot', src: 'shareSnapshot.html'});
+      }
+
+      if (!$scope.dashboard.meta.isSnapshot) {
+        $scope.tabs.push({title: 'Export', src: 'shareExport.html'});
       }
 
       $scope.buildUrl();

+ 84 - 0
public/app/features/dashboard/specs/dash_import_ctrl_specs.ts

@@ -0,0 +1,84 @@
+import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
+
+import {DashImportCtrl} from 'app/features/dashboard/import/dash_import';
+import config from 'app/core/config';
+
+describe('DashImportCtrl', function() {
+  var ctx: any = {};
+  var backendSrv = {
+    search: sinon.stub().returns(Promise.resolve([])),
+    get: sinon.stub()
+  };
+
+  beforeEach(angularMocks.module('grafana.core'));
+
+  beforeEach(angularMocks.inject(($rootScope, $controller, $q) => {
+    ctx.$q = $q;
+    ctx.scope = $rootScope.$new();
+    ctx.ctrl = $controller(DashImportCtrl, {
+      $scope: ctx.scope,
+      backendSrv: backendSrv,
+    });
+  }));
+
+  describe('when uploading json', function() {
+    beforeEach(function() {
+      config.datasources = {
+        ds: {
+          type: 'test-db',
+        }
+      };
+
+      ctx.ctrl.onUpload({
+        '__inputs': [
+          {name: 'ds', pluginId: 'test-db', type: 'datasource', pluginName: 'Test DB'}
+        ]
+      });
+    });
+
+    it('should build input model', function() {
+      expect(ctx.ctrl.inputs.length).to.eql(1);
+      expect(ctx.ctrl.inputs[0].name).to.eql('ds');
+      expect(ctx.ctrl.inputs[0].info).to.eql('Select a Test DB data source');
+    });
+
+    it('should set inputValid to false', function() {
+      expect(ctx.ctrl.inputsValid).to.eql(false);
+    });
+  });
+
+  describe('when specifing grafana.net url', function() {
+    beforeEach(function() {
+      ctx.ctrl.gnetUrl = 'http://grafana.net/dashboards/123';
+      // setup api mock
+      backendSrv.get = sinon.spy(() => {
+        return Promise.resolve({
+        });
+      });
+      ctx.ctrl.checkGnetDashboard();
+    });
+
+    it('should call gnet api with correct dashboard id', function() {
+      expect(backendSrv.get.getCall(0).args[0]).to.eql('api/gnet/dashboards/123');
+    });
+  });
+
+  describe('when specifing dashbord id', function() {
+    beforeEach(function() {
+      ctx.ctrl.gnetUrl = '2342';
+      // setup api mock
+      backendSrv.get = sinon.spy(() => {
+        return Promise.resolve({
+        });
+      });
+      ctx.ctrl.checkGnetDashboard();
+    });
+
+    it('should call gnet api with correct dashboard id', function() {
+      expect(backendSrv.get.getCall(0).args[0]).to.eql('api/gnet/dashboards/2342');
+    });
+  });
+
+});
+
+

+ 264 - 0
public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts

@@ -0,0 +1,264 @@
+import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
+
+import 'app/features/dashboard/dashboardSrv';
+import {DynamicDashboardSrv} from '../dynamic_dashboard_srv';
+
+function dynamicDashScenario(desc, func)  {
+
+  describe(desc, function() {
+    var ctx: any = {};
+
+    ctx.setup = function (setupFunc) {
+
+      beforeEach(angularMocks.module('grafana.services'));
+      beforeEach(angularMocks.module(function($provide) {
+        $provide.value('contextSrv', {
+          user: { timezone: 'utc'}
+        });
+      }));
+
+      beforeEach(angularMocks.inject(function(dashboardSrv) {
+        ctx.dashboardSrv = dashboardSrv;
+        var model = {
+          rows: [],
+          templating: { list: [] }
+        };
+
+        setupFunc(model);
+        ctx.dash = ctx.dashboardSrv.create(model);
+        ctx.dynamicDashboardSrv = new DynamicDashboardSrv();
+        ctx.dynamicDashboardSrv.init(ctx.dash);
+        ctx.rows = ctx.dash.rows;
+      }));
+    };
+
+    func(ctx);
+  });
+}
+
+dynamicDashScenario('given dashboard with panel repeat', function(ctx) {
+  ctx.setup(function(dash) {
+    dash.rows.push({
+      panels: [{id: 2, repeat: 'apps'}]
+    });
+    dash.templating.list.push({
+      name: 'apps',
+      current: {
+        text: 'se1, se2, se3',
+        value: ['se1', 'se2', 'se3']
+      },
+      options: [
+        {text: 'se1', value: 'se1', selected: true},
+        {text: 'se2', value: 'se2', selected: true},
+        {text: 'se3', value: 'se3', selected: true},
+        {text: 'se4', value: 'se4', selected: false}
+      ]
+    });
+  });
+
+  it('should repeat panel one time', function() {
+    expect(ctx.rows[0].panels.length).to.be(3);
+  });
+
+  it('should mark panel repeated', function() {
+    expect(ctx.rows[0].panels[0].repeat).to.be('apps');
+    expect(ctx.rows[0].panels[1].repeatPanelId).to.be(2);
+  });
+
+  it('should set scopedVars on panels', function() {
+    expect(ctx.rows[0].panels[0].scopedVars.apps.value).to.be('se1');
+    expect(ctx.rows[0].panels[1].scopedVars.apps.value).to.be('se2');
+    expect(ctx.rows[0].panels[2].scopedVars.apps.value).to.be('se3');
+  });
+
+  describe('After a second iteration', function() {
+    var repeatedPanelAfterIteration1;
+
+    beforeEach(function() {
+      repeatedPanelAfterIteration1 = ctx.rows[0].panels[1];
+      ctx.rows[0].panels[0].fill = 10;
+      ctx.dynamicDashboardSrv.update(ctx.dash);
+    });
+
+    it('should have reused same panel instances', function() {
+      expect(ctx.rows[0].panels[1]).to.be(repeatedPanelAfterIteration1);
+    });
+
+    it('reused panel should copy properties from source', function() {
+      expect(ctx.rows[0].panels[1].fill).to.be(10);
+    });
+
+    it('should have same panel count', function() {
+      expect(ctx.rows[0].panels.length).to.be(3);
+    });
+  });
+
+  describe('After a second iteration and selected values reduced', function() {
+    beforeEach(function() {
+      ctx.dash.templating.list[0].options[1].selected = false;
+
+      ctx.dynamicDashboardSrv.update(ctx.dash);
+    });
+
+    it('should clean up repeated panel', function() {
+      expect(ctx.rows[0].panels.length).to.be(2);
+    });
+  });
+
+  describe('After a second iteration and panel repeat is turned off', function() {
+    beforeEach(function() {
+      ctx.rows[0].panels[0].repeat = null;
+      ctx.dynamicDashboardSrv.update(ctx.dash);
+    });
+
+    it('should clean up repeated panel', function() {
+      expect(ctx.rows[0].panels.length).to.be(1);
+    });
+
+    it('should remove scoped vars from reused panel', function() {
+      expect(ctx.rows[0].panels[0].scopedVars).to.be.empty();
+    });
+  });
+
+});
+
+dynamicDashScenario('given dashboard with row repeat', function(ctx) {
+  ctx.setup(function(dash) {
+    dash.rows.push({
+      repeat: 'servers',
+      panels: [{id: 2}]
+    });
+    dash.rows.push({panels: []});
+    dash.templating.list.push({
+      name: 'servers',
+      current: {
+        text: 'se1, se2',
+        value: ['se1', 'se2']
+      },
+      options: [
+        {text: 'se1', value: 'se1', selected: true},
+        {text: 'se2', value: 'se2', selected: true},
+      ]
+    });
+  });
+
+  it('should repeat row one time', function() {
+    expect(ctx.rows.length).to.be(3);
+  });
+
+  it('should keep panel ids on first row', function() {
+    expect(ctx.rows[0].panels[0].id).to.be(2);
+  });
+
+  it('should keep first row as repeat', function() {
+    expect(ctx.rows[0].repeat).to.be('servers');
+  });
+
+  it('should clear repeat field on repeated row', function() {
+    expect(ctx.rows[1].repeat).to.be(null);
+  });
+
+  it('should add scopedVars to rows', function() {
+    expect(ctx.rows[0].scopedVars.servers.value).to.be('se1');
+    expect(ctx.rows[1].scopedVars.servers.value).to.be('se2');
+  });
+
+  it('should generate a repeartRowId based on repeat row index', function() {
+    expect(ctx.rows[1].repeatRowId).to.be(1);
+  });
+
+  it('should set scopedVars on row panels', function() {
+    expect(ctx.rows[0].panels[0].scopedVars.servers.value).to.be('se1');
+    expect(ctx.rows[1].panels[0].scopedVars.servers.value).to.be('se2');
+  });
+
+  describe('After a second iteration', function() {
+    var repeatedRowAfterFirstIteration;
+
+    beforeEach(function() {
+      repeatedRowAfterFirstIteration = ctx.rows[1];
+      ctx.rows[0].height = 500;
+      ctx.dynamicDashboardSrv.update(ctx.dash);
+    });
+
+    it('should still only have 2 rows', function() {
+      expect(ctx.rows.length).to.be(3);
+    });
+
+    it.skip('should have updated props from source', function() {
+      expect(ctx.rows[1].height).to.be(500);
+    });
+
+    it('should reuse row instance', function() {
+      expect(ctx.rows[1]).to.be(repeatedRowAfterFirstIteration);
+    });
+  });
+
+  describe('After a second iteration and selected values reduced', function() {
+    beforeEach(function() {
+      ctx.dash.templating.list[0].options[1].selected = false;
+      ctx.dynamicDashboardSrv.update(ctx.dash);
+    });
+
+    it('should remove repeated second row', function() {
+      expect(ctx.rows.length).to.be(2);
+    });
+  });
+});
+
+dynamicDashScenario('given dashboard with row repeat and panel repeat', function(ctx) {
+  ctx.setup(function(dash) {
+    dash.rows.push({
+      repeat: 'servers',
+      panels: [{id: 2, repeat: 'metric'}]
+    });
+    dash.templating.list.push({
+      name: 'servers',
+      current: { text: 'se1, se2', value: ['se1', 'se2'] },
+      options: [
+        {text: 'se1', value: 'se1', selected: true},
+        {text: 'se2', value: 'se2', selected: true},
+      ]
+    });
+    dash.templating.list.push({
+      name: 'metric',
+      current: { text: 'm1, m2', value: ['m1', 'm2'] },
+      options: [
+        {text: 'm1', value: 'm1', selected: true},
+        {text: 'm2', value: 'm2', selected: true},
+      ]
+    });
+  });
+
+  it('should repeat row one time', function() {
+    expect(ctx.rows.length).to.be(2);
+  });
+
+  it('should repeat panel on both rows', function() {
+    expect(ctx.rows[0].panels.length).to.be(2);
+    expect(ctx.rows[1].panels.length).to.be(2);
+  });
+
+  it('should keep panel ids on first row', function() {
+    expect(ctx.rows[0].panels[0].id).to.be(2);
+  });
+
+  it('should mark second row as repeated', function() {
+    expect(ctx.rows[0].repeat).to.be('servers');
+  });
+
+  it('should clear repeat field on repeated row', function() {
+    expect(ctx.rows[1].repeat).to.be(null);
+  });
+
+  it('should generate a repeartRowId based on repeat row index', function() {
+    expect(ctx.rows[1].repeatRowId).to.be(1);
+  });
+
+  it('should set scopedVars on row panels', function() {
+    expect(ctx.rows[0].panels[0].scopedVars.servers.value).to.be('se1');
+    expect(ctx.rows[1].panels[0].scopedVars.servers.value).to.be('se2');
+  });
+
+});
+

+ 142 - 0
public/app/features/dashboard/specs/exporter_specs.ts

@@ -0,0 +1,142 @@
+import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
+
+import _ from 'lodash';
+import config from 'app/core/config';
+import {DashboardExporter} from '../export/exporter';
+
+describe('given dashboard with repeated panels', function() {
+  var dash, exported;
+
+  beforeEach(done => {
+    dash = {
+      rows: [],
+      templating: { list: [] },
+      annotations: { list: [] },
+    };
+
+    config.buildInfo = {
+      version: "3.0.2"
+    };
+
+    dash.templating.list.push({
+      name: 'apps',
+      type: 'query',
+      datasource: 'gfdb',
+      current: {value: 'Asd', text: 'Asd'},
+      options: [{value: 'Asd', text: 'Asd'}]
+    });
+
+    dash.templating.list.push({
+      name: 'prefix',
+      type: 'constant',
+      current: {value: 'collectd', text: 'collectd'},
+      options: []
+    });
+
+    dash.annotations.list.push({
+      name: 'logs',
+      datasource: 'gfdb',
+    });
+
+    dash.rows.push({
+      repeat: 'test',
+      panels: [
+        {id: 2, repeat: 'apps', datasource: 'gfdb', type: 'graph'},
+        {id: 2, repeat: null, repeatPanelId: 2},
+      ]
+    });
+    dash.rows.push({
+      repeat: null,
+      repeatRowId: 1
+    });
+
+    var datasourceSrvStub = {
+      get: sinon.stub().returns(Promise.resolve({
+        name: 'gfdb',
+        meta: {id: "testdb", info: {version: "1.2.1"}, name: "TestDB"}
+      }))
+    };
+
+    config.panels['graph'] = {
+      id: "graph",
+      name: "Graph",
+      info: {version: "1.1.0"}
+    };
+
+    var exporter = new DashboardExporter(datasourceSrvStub);
+    exporter.makeExportable(dash).then(clean => {
+      exported = clean;
+      done();
+    });
+  });
+
+  it('exported dashboard should not contain repeated panels', function() {
+    expect(exported.rows[0].panels.length).to.be(1);
+  });
+
+  it('exported dashboard should not contain repeated rows', function() {
+    expect(exported.rows.length).to.be(1);
+  });
+
+  it('should replace datasource refs', function() {
+    var panel = exported.rows[0].panels[0];
+    expect(panel.datasource).to.be("${DS_GFDB}");
+  });
+
+  it('should replace datasource in variable query', function() {
+    expect(exported.templating.list[0].datasource).to.be("${DS_GFDB}");
+    expect(exported.templating.list[0].options.length).to.be(0);
+    expect(exported.templating.list[0].current.value).to.be(undefined);
+    expect(exported.templating.list[0].current.text).to.be(undefined);
+  });
+
+  it('should replace datasource in annotation query', function() {
+    expect(exported.annotations.list[0].datasource).to.be("${DS_GFDB}");
+  });
+
+  it('should add datasource as input', function() {
+    expect(exported.__inputs[0].name).to.be("DS_GFDB");
+    expect(exported.__inputs[0].pluginId).to.be("testdb");
+    expect(exported.__inputs[0].type).to.be("datasource");
+  });
+
+  it('should add datasource to required', function() {
+    var require = _.findWhere(exported.__requires, {name: 'TestDB'});
+    expect(require.name).to.be("TestDB");
+    expect(require.id).to.be("testdb");
+    expect(require.type).to.be("datasource");
+    expect(require.version).to.be("1.2.1");
+  });
+
+  it('should add panel to required', function() {
+    var require = _.findWhere(exported.__requires, {name: 'Graph'});
+    expect(require.name).to.be("Graph");
+    expect(require.id).to.be("graph");
+    expect(require.version).to.be("1.1.0");
+  });
+
+  it('should add grafana version', function() {
+    var require = _.findWhere(exported.__requires, {name: 'Grafana'});
+    expect(require.type).to.be("grafana");
+    expect(require.id).to.be("grafana");
+    expect(require.version).to.be("3.0.2");
+  });
+
+  it('should add constant template variables as inputs', function() {
+    var input = _.findWhere(exported.__inputs, {name: 'VAR_PREFIX'});
+    expect(input.type).to.be("constant");
+    expect(input.label).to.be("prefix");
+    expect(input.value).to.be("collectd");
+  });
+
+  it('should templatize constant variables', function() {
+    var variable = _.findWhere(exported.templating.list, {name: 'prefix'});
+    expect(variable.query).to.be("${VAR_PREFIX}");
+    expect(variable.current.text).to.be("${VAR_PREFIX}");
+    expect(variable.current.value).to.be("${VAR_PREFIX}");
+    expect(variable.options[0].text).to.be("${VAR_PREFIX}");
+    expect(variable.options[0].value).to.be("${VAR_PREFIX}");
+  });
+
+});
+

+ 0 - 2
public/app/features/dashboard/submenu/submenu.ts

@@ -12,7 +12,6 @@ export class SubmenuCtrl {
   constructor(private $rootScope,
               private templateValuesSrv,
               private templateSrv,
-              private dynamicDashboardSrv,
               private $location) {
     this.annotations = this.dashboard.templating.list;
     this.variables = this.dashboard.templating.list;
@@ -29,7 +28,6 @@ export class SubmenuCtrl {
 
   variableUpdated(variable) {
     this.templateValuesSrv.variableUpdated(variable).then(() => {
-      this.dynamicDashboardSrv.update(this.dashboard);
       this.$rootScope.$emit('template-variable-value-updated');
       this.$rootScope.$broadcast('refresh');
     });

+ 61 - 0
public/app/features/dashboard/upload.ts

@@ -0,0 +1,61 @@
+///<reference path="../../headers/common.d.ts" />
+
+import kbn from 'app/core/utils/kbn';
+import coreModule from 'app/core/core_module';
+
+var template = `
+<input type="file" id="dashupload" name="dashupload" class="hide"/>
+<label class="btn btn-secondary" for="dashupload">
+  <i class="fa fa-upload"></i>
+  Upload .json File
+</label>
+`;
+
+/** @ngInject */
+function uploadDashboardDirective(timer, alertSrv, $location) {
+  return {
+    restrict: 'E',
+    template: template,
+    scope: {
+      onUpload: '&',
+    },
+    link: function(scope) {
+      function file_selected(evt) {
+        var files = evt.target.files; // FileList object
+        var readerOnload = function() {
+          return function(e) {
+            var dash;
+            try {
+              dash = JSON.parse(e.target.result);
+            } catch (err) {
+              console.log(err);
+              scope.appEvent('alert-error', ['Import failed', 'JSON -> JS Serialization failed: ' + err.message]);
+              return;
+            }
+
+            scope.$apply(function() {
+              scope.onUpload({dash: dash});
+            });
+          };
+        };
+
+        for (var i = 0, f; f = files[i]; i++) {
+          var reader = new FileReader();
+          reader.onload = readerOnload();
+          reader.readAsText(f);
+        }
+      }
+
+      var wnd: any = window;
+      // Check for the various File API support.
+      if (wnd.File && wnd.FileReader && wnd.FileList && wnd.Blob) {
+        // Something
+        document.getElementById('dashupload').addEventListener('change', file_selected, false);
+      } else {
+        alertSrv.set('Oops','Sorry, the HTML5 File APIs are not fully supported in this browser.','error');
+      }
+    }
+  };
+}
+
+coreModule.directive('dashUpload', uploadDashboardDirective);

+ 6 - 6
public/app/features/plugins/import_list/import_list.html

@@ -6,27 +6,27 @@
 					<i class="icon-gf icon-gf-dashboard"></i>
 				</td>
 				<td>
-					<a href="dashboard/{{dash.installedUri}}" ng-show="dash.installed">
+					<a href="dashboard/{{dash.importedUri}}" ng-show="dash.imported">
 						{{dash.title}}
 					</a>
-					<span ng-show="!dash.installed">
+					<span ng-show="!dash.imported">
 						{{dash.title}}
 					</span>
 				</td>
 				<td>
 					v{{dash.revision}}
 					<span ng-if="dash.installed">
-						&nbsp;(Imported v{{dash.installedRevision}})
+						&nbsp;(Imported v{{dash.importedRevision}})
 					<span>
 				</td>
 				<td style="text-align: right">
-					<button class="btn btn-secondary" ng-click="ctrl.import(dash, false)" ng-show="!dash.installed">
+					<button class="btn btn-secondary" ng-click="ctrl.import(dash, false)" ng-show="!dash.imported">
 						Import
 					</button>
-					<button class="btn btn-secondary" ng-click="ctrl.import(dash, true)" ng-show="dash.installed">
+					<button class="btn btn-secondary" ng-click="ctrl.import(dash, true)" ng-show="dash.imported">
 						Update
 					</button>
-					<button class="btn btn-danger" ng-click="ctrl.remove(dash)" ng-show="dash.installed">
+					<button class="btn btn-danger" ng-click="ctrl.remove(dash)" ng-show="dash.imported">
 						Delete
 					</button>
 				</td>

+ 3 - 7
public/app/features/plugins/import_list/import_list.ts

@@ -61,15 +61,15 @@ export class DashImportListCtrl {
     }
 
     return this.backendSrv.post(`/api/dashboards/import`, installCmd).then(res => {
-      this.$rootScope.appEvent('alert-success', ['Dashboard Installed', dash.title]);
+      this.$rootScope.appEvent('alert-success', ['Dashboard Imported', dash.title]);
       _.extend(dash, res);
     });
   }
 
   remove(dash) {
-    this.backendSrv.delete('/api/dashboards/' + dash.installedUri).then(() => {
+    this.backendSrv.delete('/api/dashboards/' + dash.importedUri).then(() => {
       this.$rootScope.appEvent('alert-success', ['Dashboard Deleted', dash.title]);
-      dash.installed = false;
+      dash.imported = false;
     });
   }
 }
@@ -89,7 +89,3 @@ export function dashboardImportList() {
 }
 
 coreModule.directive('dashboardImportList', dashboardImportList);
-
-
-
-

+ 0 - 1
public/app/features/plugins/plugin_edit_ctrl.ts

@@ -88,7 +88,6 @@ export class PluginEditCtrl {
         jsonData: this.model.jsonData,
         secureJsonData: this.model.secureJsonData,
       }, {});
-
       return this.backendSrv.post(`/api/plugins/${this.pluginId}/settings`, updateCmd);
     })
     .then(this.postUpdateHook)

+ 0 - 4
public/app/partials/help_modal.html

@@ -40,10 +40,6 @@
 				<td><span class="label label-info">CTRL+S</span></td>
 				<td>Save dashboard</td>
 			</tr>
-			<tr>
-				<td><span class="label label-info">CTRL+E</span></td>
-				<td>Export dashboard</td>
-			</tr>
 			<tr>
 				<td><span class="label label-info">CTRL+H</span></td>
 				<td>Hide row controls</td>

+ 0 - 1
public/dashboards/home.json

@@ -1,7 +1,6 @@
 {
   "id": null,
   "title": "Home",
-  "originalTitle": "Home",
   "tags": [],
   "style": "dark",
   "timezone": "browser",

+ 0 - 1
public/dashboards/template_vars.json

@@ -1,7 +1,6 @@
 {
   "id": null,
   "title": "Templated Graphs Nested",
-  "originalTitle": "Templated Graphs Nested",
   "tags": [
     "showcase",
     "templated"

+ 3 - 3
public/sass/_variables.dark.scss

@@ -232,13 +232,13 @@ $paginationActiveBackground:          $blue;
 
 // Form states and alerts
 // -------------------------
-$state-warning-text:      darken(#c09853, 10%);
+$state-warning-text:      $warn;
 $state-warning-bg:        $brand-warning;
 
-$errorText:               #b94a48;
+$errorText:               #E84D4D;
 $errorBackground:         $btn-danger-bg;
 
-$successText:             #468847;
+$successText:             #12D95A;
 $successBackground:       $btn-success-bg;
 
 $infoText:                $blue-dark;

+ 10 - 0
public/sass/components/_dropdown.scss

@@ -17,6 +17,16 @@
   outline: 0;
 }
 
+.dropdown-desc {
+    position: relative;
+    top: -3px;
+    width: 250px;
+    font-size: 80%;
+    margin-left: 22px;
+    color: $gray-2;
+    white-space: normal;
+}
+
 // Dropdown arrow/caret
 // --------------------
 .caret {

+ 4 - 0
public/sass/components/_gf-form.scss

@@ -158,6 +158,10 @@ $gf-form-margin: 0.25rem;
       color: transparent;
       text-shadow: 0 0 0 $text-color;
     }
+
+    &.ng-empty {
+      color: $text-color-weak;
+    }
   }
 
   &:after {

+ 14 - 11
public/sass/components/_modals.scss

@@ -118,7 +118,6 @@
 }
 
 .share-modal-body {
-  text-align: center;
   padding: 10px 0;
 
   .tight-form {
@@ -126,35 +125,40 @@
   }
 
   .share-modal-options {
-    margin: 11px 20px 33px 20px;
+    margin: 11px 0px 33px 0px;
     display: inline-block;
   }
 
   .share-modal-big-icon {
-    margin-bottom: 2rem;
-
+    margin-bottom: 10px;
+    margin-right: 2rem;
     .fa, .icon-gf {
-      font-size: 70px;
+      font-size: 50px;
     }
   }
 
-  .share-snapshot-info-text {
-    margin: 10px 105px;
+  .share-modal-info-text {
+    margin-top: 5px;
     strong {
       color: $headings-color;
       font-weight: 500;
     }
   }
 
-  .share-snapshot-header {
-    margin: 20px 0 22px 0;
+  .share-modal-header {
+    display: flex;
+    margin: 0px 0 22px 0;
+  }
+
+  .share-modal-content {
+    flex-grow: 1;
   }
 
   .tight-form {
     text-align: left;
   }
 
-  .share-snapshot-link {
+  .share-modal-link {
     max-width: 716px;
     white-space: nowrap;
     overflow: hidden;
@@ -162,4 +166,3 @@
     text-overflow: ellipsis;
   }
 }
-

+ 2 - 0
public/sass/utils/_validation.scss

@@ -1,8 +1,10 @@
 input[type=text].ng-dirty.ng-invalid {
 }
 
+input.validation-error,
 input.ng-dirty.ng-invalid {
   box-shadow: inset 0 0px 5px $red;
 }
 
 
+

+ 8 - 3
public/vendor/angular-other/angular-strap.js

@@ -25,11 +25,16 @@ angular.module('$strap.directives').factory('$modal', [
   function ($rootScope, $compile, $http, $timeout, $q, $templateCache, $strapConfig) {
     var ModalFactory = function ModalFactory(config) {
       function Modal(config) {
-        var options = angular.extend({ show: true }, $strapConfig.modal, config), scope = options.scope ? options.scope : $rootScope.$new(), templateUrl = options.template;
-        return $q.when($templateCache.get(templateUrl) || $http.get(templateUrl, { cache: true }).then(function (res) {
+        var options = angular.extend({ show: true }, $strapConfig.modal, config);
+        var scope = options.scope ? options.scope : $rootScope.$new()
+        var templateUrl = options.template;
+        return $q.when(options.templateHtml || $templateCache.get(templateUrl) || $http.get(templateUrl, { cache: true }).then(function (res) {
           return res.data;
         })).then(function onSuccess(template) {
-          var id = templateUrl.replace('.html', '').replace(/[\/|\.|:]/g, '-') + '-' + scope.$id;
+          var id = scope.$id;
+          if (templateUrl) {
+            id += templateUrl.replace('.html', '').replace(/[\/|\.|:]/g, '-');
+          }
           // grafana change, removed fade
           var $modal = $('<div class="modal hide" tabindex="-1"></div>').attr('id', id).html(template);
           if (options.modalClass)