mirror of
https://github.com/grafana/grafana.git
synced 2025-01-27 00:37:04 -06:00
Merge remote-tracking branch 'origin/7883_frontend_step' into 7883_backend
* origin/7883_frontend_step: dashboards: make scripted dashboards work using the old legacy urls dashboards: redirect from old url used to load dashboard to new url dashboards: add new default frontend route for rendering a dashboard panel dashboards: fix links to recently viewed and starred dashboards dashboards: use new *url* prop from dashboard search for linking to dashboards dashboards: when saving dashboard redirect if url changes dashboards: add new default frontend route for loading a dashboard dashboards: return url in response to save dashboard. #7883
This commit is contained in:
commit
9aa488c084
@ -15,6 +15,8 @@ func (hs *HttpServer) registerRoutes() {
|
||||
reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
|
||||
reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
|
||||
reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
|
||||
redirectFromLegacyDashboardUrl := middleware.RedirectFromLegacyDashboardUrl()
|
||||
redirectFromLegacyDashboardSoloUrl := middleware.RedirectFromLegacyDashboardSoloUrl()
|
||||
quota := middleware.Quota
|
||||
bind := binding.Bind
|
||||
|
||||
@ -63,9 +65,13 @@ func (hs *HttpServer) registerRoutes() {
|
||||
r.Get("/plugins/:id/edit", reqSignedIn, Index)
|
||||
r.Get("/plugins/:id/page/:page", reqSignedIn, Index)
|
||||
|
||||
r.Get("/dashboard/*", reqSignedIn, Index)
|
||||
r.Get("/d/:uid/:slug", reqSignedIn, Index)
|
||||
r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardUrl, Index)
|
||||
r.Get("/dashboard/script/*", reqSignedIn, Index)
|
||||
r.Get("/dashboard-solo/snapshot/*", Index)
|
||||
r.Get("/dashboard-solo/*", reqSignedIn, Index)
|
||||
r.Get("/d-solo/:uid/:slug", reqSignedIn, Index)
|
||||
r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloUrl, Index)
|
||||
r.Get("/dashboard-solo/script/*", reqSignedIn, Index)
|
||||
r.Get("/import/dashboard", reqSignedIn, Index)
|
||||
r.Get("/dashboards/", reqSignedIn, Index)
|
||||
r.Get("/dashboards/*", reqSignedIn, Index)
|
||||
|
@ -238,8 +238,22 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
|
||||
return ApiError(500, "Invalid alert data. Cannot save dashboard", err)
|
||||
}
|
||||
|
||||
var url string
|
||||
if dash.IsFolder {
|
||||
url = m.GetFolderUrl(dashboard.Uid, dashboard.Slug)
|
||||
} else {
|
||||
url = m.GetDashboardUrl(dashboard.Uid, dashboard.Slug)
|
||||
}
|
||||
|
||||
c.TimeRequest(metrics.M_Api_Dashboard_Save)
|
||||
return Json(200, util.DynMap{"status": "success", "slug": dashboard.Slug, "version": dashboard.Version, "id": dashboard.Id, "uid": dashboard.Uid})
|
||||
return Json(200, util.DynMap{
|
||||
"status": "success",
|
||||
"slug": dashboard.Slug,
|
||||
"version": dashboard.Version,
|
||||
"id": dashboard.Id,
|
||||
"uid": dashboard.Uid,
|
||||
"url": url,
|
||||
})
|
||||
}
|
||||
|
||||
func GetHomeDashboard(c *middleware.Context) Response {
|
||||
|
@ -180,13 +180,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
|
||||
postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
|
||||
CallPostDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
result := sc.ToJson()
|
||||
So(result.Get("status").MustString(), ShouldEqual, "success")
|
||||
So(result.Get("id").MustInt64(), ShouldBeGreaterThan, 0)
|
||||
So(result.Get("uid").MustString(), ShouldNotBeNil)
|
||||
So(result.Get("slug").MustString(), ShouldNotBeNil)
|
||||
CallPostDashboardShouldReturnSuccess(sc)
|
||||
})
|
||||
|
||||
Convey("When saving a dashboard folder in another folder", func() {
|
||||
@ -423,13 +417,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
|
||||
postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
|
||||
CallPostDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
result := sc.ToJson()
|
||||
So(result.Get("status").MustString(), ShouldEqual, "success")
|
||||
So(result.Get("id").MustInt64(), ShouldBeGreaterThan, 0)
|
||||
So(result.Get("uid").MustString(), ShouldNotBeNil)
|
||||
So(result.Get("slug").MustString(), ShouldNotBeNil)
|
||||
CallPostDashboardShouldReturnSuccess(sc)
|
||||
})
|
||||
})
|
||||
|
||||
@ -544,13 +532,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
|
||||
postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
|
||||
CallPostDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
result := sc.ToJson()
|
||||
So(result.Get("status").MustString(), ShouldEqual, "success")
|
||||
So(result.Get("id").MustInt64(), ShouldBeGreaterThan, 0)
|
||||
So(result.Get("uid").MustString(), ShouldNotBeNil)
|
||||
So(result.Get("slug").MustString(), ShouldNotBeNil)
|
||||
CallPostDashboardShouldReturnSuccess(sc)
|
||||
})
|
||||
})
|
||||
|
||||
@ -678,6 +660,18 @@ func CallPostDashboard(sc *scenarioContext) {
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
}
|
||||
|
||||
func CallPostDashboardShouldReturnSuccess(sc *scenarioContext) {
|
||||
CallPostDashboard(sc)
|
||||
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
result := sc.ToJson()
|
||||
So(result.Get("status").MustString(), ShouldEqual, "success")
|
||||
So(result.Get("id").MustInt64(), ShouldBeGreaterThan, 0)
|
||||
So(result.Get("uid").MustString(), ShouldNotBeNil)
|
||||
So(result.Get("slug").MustString(), ShouldNotBeNil)
|
||||
So(result.Get("url").MustString(), ShouldNotBeNil)
|
||||
}
|
||||
|
||||
func postDashboardScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.SaveDashboardCommand, fn scenarioFunc) {
|
||||
Convey(desc+" "+url, func() {
|
||||
defer bus.ClearBusHandlers()
|
||||
|
46
pkg/middleware/dashboard_redirect.go
Normal file
46
pkg/middleware/dashboard_redirect.go
Normal file
@ -0,0 +1,46 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"gopkg.in/macaron.v1"
|
||||
)
|
||||
|
||||
func getDashboardUrlBySlug(orgId int64, slug string) (string, error) {
|
||||
query := m.GetDashboardQuery{Slug: slug, OrgId: orgId}
|
||||
|
||||
if err := bus.Dispatch(&query); err != nil {
|
||||
return "", m.ErrDashboardNotFound
|
||||
}
|
||||
|
||||
return m.GetDashboardUrl(query.Result.Uid, query.Result.Slug), nil
|
||||
}
|
||||
|
||||
func RedirectFromLegacyDashboardUrl() macaron.Handler {
|
||||
return func(c *Context) {
|
||||
slug := c.Params("slug")
|
||||
|
||||
if slug != "" {
|
||||
if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil {
|
||||
c.Redirect(url, 301)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RedirectFromLegacyDashboardSoloUrl() macaron.Handler {
|
||||
return func(c *Context) {
|
||||
slug := c.Params("slug")
|
||||
|
||||
if slug != "" {
|
||||
if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil {
|
||||
url = strings.Replace(url, "/d/", "/d-solo/", 1)
|
||||
c.Redirect(url, 301)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
54
pkg/middleware/dashboard_redirect_test.go
Normal file
54
pkg/middleware/dashboard_redirect_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestMiddlewareDashboardRedirect(t *testing.T) {
|
||||
Convey("Given the dashboard redirect middleware", t, func() {
|
||||
bus.ClearBusHandlers()
|
||||
redirectFromLegacyDashboardUrl := RedirectFromLegacyDashboardUrl()
|
||||
redirectFromLegacyDashboardSoloUrl := RedirectFromLegacyDashboardSoloUrl()
|
||||
|
||||
fakeDash := m.NewDashboard("Child dash")
|
||||
fakeDash.Id = 1
|
||||
fakeDash.FolderId = 1
|
||||
fakeDash.HasAcl = false
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
|
||||
query.Result = fakeDash
|
||||
return nil
|
||||
})
|
||||
|
||||
middlewareScenario("GET dashboard by legacy url", func(sc *scenarioContext) {
|
||||
sc.m.Get("/dashboard/db/:slug", redirectFromLegacyDashboardUrl, sc.defaultHandler)
|
||||
|
||||
sc.fakeReqWithParams("GET", "/dashboard/db/dash", map[string]string{}).exec()
|
||||
|
||||
Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 301)
|
||||
redirectUrl, _ := sc.resp.Result().Location()
|
||||
So(redirectUrl.Path, ShouldEqual, m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug))
|
||||
})
|
||||
})
|
||||
|
||||
middlewareScenario("GET dashboard solo by legacy url", func(sc *scenarioContext) {
|
||||
sc.m.Get("/dashboard-solo/db/:slug", redirectFromLegacyDashboardSoloUrl, sc.defaultHandler)
|
||||
|
||||
sc.fakeReqWithParams("GET", "/dashboard-solo/db/dash", map[string]string{}).exec()
|
||||
|
||||
Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 301)
|
||||
redirectUrl, _ := sc.resp.Result().Location()
|
||||
expectedUrl := m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug)
|
||||
expectedUrl = strings.Replace(expectedUrl, "/d/", "/d-solo/", 1)
|
||||
So(redirectUrl.Path, ShouldEqual, expectedUrl)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
@ -399,6 +399,20 @@ func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
|
||||
return sc
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext {
|
||||
sc.resp = httptest.NewRecorder()
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
q := req.URL.Query()
|
||||
for k, v := range queryParams {
|
||||
q.Add(k, v)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
So(err, ShouldBeNil)
|
||||
sc.req = req
|
||||
|
||||
return sc
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) handler(fn handlerFunc) *scenarioContext {
|
||||
sc.handlerFunc = fn
|
||||
return sc
|
||||
|
@ -225,6 +225,10 @@ export class BackendSrv {
|
||||
return this.get('/api/dashboards/' + type + '/' + slug);
|
||||
}
|
||||
|
||||
getDashboardByUid(uid: string) {
|
||||
return this.get(`/api/dashboards/uid/${uid}`);
|
||||
}
|
||||
|
||||
saveDashboard(dash, options) {
|
||||
options = options || {};
|
||||
|
||||
|
@ -41,10 +41,7 @@ export class SearchSrv {
|
||||
.map(orderId => {
|
||||
return _.find(result, { id: orderId });
|
||||
})
|
||||
.filter(hit => hit && !hit.isStarred)
|
||||
.map(hit => {
|
||||
return this.transformToViewModel(hit);
|
||||
});
|
||||
.filter(hit => hit && !hit.isStarred);
|
||||
});
|
||||
}
|
||||
|
||||
@ -81,17 +78,12 @@ export class SearchSrv {
|
||||
score: -2,
|
||||
expanded: this.starredIsOpen,
|
||||
toggle: this.toggleStarred.bind(this),
|
||||
items: result.map(this.transformToViewModel),
|
||||
items: result,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private transformToViewModel(hit) {
|
||||
hit.url = 'dashboard/db/' + hit.slug;
|
||||
return hit;
|
||||
}
|
||||
|
||||
search(options) {
|
||||
let sections: any = {};
|
||||
let promises = [];
|
||||
@ -181,7 +173,7 @@ export class SearchSrv {
|
||||
}
|
||||
|
||||
section.expanded = true;
|
||||
section.items.push(this.transformToViewModel(hit));
|
||||
section.items.push(hit);
|
||||
}
|
||||
}
|
||||
|
||||
@ -198,7 +190,7 @@ export class SearchSrv {
|
||||
};
|
||||
|
||||
return this.backendSrv.search(query).then(results => {
|
||||
section.items = _.map(results, this.transformToViewModel);
|
||||
section.items = results;
|
||||
return Promise.resolve(section);
|
||||
});
|
||||
}
|
||||
|
@ -35,18 +35,18 @@ export class DashboardLoaderSrv {
|
||||
};
|
||||
}
|
||||
|
||||
loadDashboard(type, slug) {
|
||||
loadDashboard(type, slug, uid) {
|
||||
var promise;
|
||||
|
||||
if (type === 'script') {
|
||||
promise = this._loadScriptedDashboard(slug);
|
||||
} else if (type === 'snapshot') {
|
||||
promise = this.backendSrv.get('/api/snapshots/' + this.$routeParams.slug).catch(() => {
|
||||
promise = this.backendSrv.get('/api/snapshots/' + slug).catch(() => {
|
||||
return this._dashboardLoadFailed('Snapshot not found', true);
|
||||
});
|
||||
} else {
|
||||
promise = this.backendSrv
|
||||
.getDashboard(this.$routeParams.type, this.$routeParams.slug)
|
||||
.getDashboardByUid(uid)
|
||||
.then(result => {
|
||||
if (result.meta.isFolder) {
|
||||
this.$rootScope.appEvent('alert-error', ['Dashboard not found']);
|
||||
|
@ -73,9 +73,8 @@ export class DashboardSrv {
|
||||
postSave(clone, data) {
|
||||
this.dash.version = data.version;
|
||||
|
||||
var dashboardUrl = '/dashboard/db/' + data.slug;
|
||||
if (dashboardUrl !== this.$location.path()) {
|
||||
this.$location.url(dashboardUrl);
|
||||
if (data.url !== this.$location.path()) {
|
||||
this.$location.url(data.url);
|
||||
}
|
||||
|
||||
this.$rootScope.appEvent('dashboard-saved', this.dash);
|
||||
|
@ -74,6 +74,7 @@ export class ShareModalCtrl {
|
||||
$scope.shareUrl = linkSrv.addParamsToUrl(baseUrl, params);
|
||||
|
||||
var soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/');
|
||||
soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/');
|
||||
delete params.fullscreen;
|
||||
delete params.edit;
|
||||
soloUrl = linkSrv.addParamsToUrl(soloUrl, params);
|
||||
@ -84,6 +85,7 @@ export class ShareModalCtrl {
|
||||
config.appSubUrl + '/dashboard-solo/',
|
||||
config.appSubUrl + '/render/dashboard-solo/'
|
||||
);
|
||||
$scope.imageUrl = $scope.imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/');
|
||||
$scope.imageUrl += '&width=1000';
|
||||
$scope.imageUrl += '&height=500';
|
||||
$scope.imageUrl += '&tz=UTC' + encodeURIComponent(moment().format('Z'));
|
||||
|
@ -43,12 +43,23 @@ describe('ShareModalCtrl', function() {
|
||||
});
|
||||
|
||||
it('should generate render url', function() {
|
||||
ctx.$location.$$absUrl = 'http://dashboards.grafana.com/dashboard/db/my-dash';
|
||||
ctx.$location.$$absUrl = 'http://dashboards.grafana.com/d/abcdefghi/my-dash';
|
||||
|
||||
ctx.scope.panel = { id: 22 };
|
||||
|
||||
ctx.scope.init();
|
||||
var base = 'http://dashboards.grafana.com/render/dashboard-solo/db/my-dash';
|
||||
var base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
|
||||
var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
|
||||
expect(ctx.scope.imageUrl).to.contain(base + params);
|
||||
});
|
||||
|
||||
it('should generate render url for scripted dashboard', function() {
|
||||
ctx.$location.$$absUrl = 'http://dashboards.grafana.com/dashboard/script/my-dash.js';
|
||||
|
||||
ctx.scope.panel = { id: 22 };
|
||||
|
||||
ctx.scope.init();
|
||||
var base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js';
|
||||
var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
|
||||
expect(ctx.scope.imageUrl).to.contain(base + params);
|
||||
});
|
||||
|
@ -2,7 +2,7 @@ import angular from 'angular';
|
||||
|
||||
export class SoloPanelCtrl {
|
||||
/** @ngInject */
|
||||
constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv) {
|
||||
constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv, backendSrv) {
|
||||
var panelId;
|
||||
|
||||
$scope.init = function() {
|
||||
@ -13,7 +13,17 @@ export class SoloPanelCtrl {
|
||||
|
||||
$scope.onAppEvent('dashboard-initialized', $scope.initPanelScope);
|
||||
|
||||
dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug).then(function(result) {
|
||||
// if no uid, redirect to new route based on slug
|
||||
if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
|
||||
backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => {
|
||||
if (res) {
|
||||
$location.path(res.meta.url.replace('/d/', '/d-solo/'));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(function(result) {
|
||||
result.meta.soloMode = true;
|
||||
$scope.initDashboard(result, $scope);
|
||||
});
|
||||
|
@ -4,7 +4,7 @@
|
||||
{{group.header}}
|
||||
</h6>
|
||||
<div class="dashlist-item" ng-repeat="dash in group.list">
|
||||
<a class="dashlist-link dashlist-link-{{dash.type}}" href="dashboard/{{dash.uri}}">
|
||||
<a class="dashlist-link dashlist-link-{{dash.type}}" href="{{dash.url}}">
|
||||
<span class="dashlist-title">
|
||||
{{dash.title}}
|
||||
</span>
|
||||
|
@ -5,7 +5,7 @@ export class LoadDashboardCtrl {
|
||||
constructor($scope, $routeParams, dashboardLoaderSrv, backendSrv, $location) {
|
||||
$scope.appEvent('dashboard-fetch-start');
|
||||
|
||||
if (!$routeParams.slug) {
|
||||
if (!$routeParams.uid && !$routeParams.slug) {
|
||||
backendSrv.get('/api/dashboards/home').then(function(homeDash) {
|
||||
if (homeDash.redirectUri) {
|
||||
$location.path('dashboard/' + homeDash.redirectUri);
|
||||
@ -18,7 +18,17 @@ export class LoadDashboardCtrl {
|
||||
return;
|
||||
}
|
||||
|
||||
dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug).then(function(result) {
|
||||
// if no uid, redirect to new route based on slug
|
||||
if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
|
||||
backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => {
|
||||
if (res) {
|
||||
$location.path(res.meta.url);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(function(result) {
|
||||
if ($routeParams.keepRows) {
|
||||
result.meta.keepRows = true;
|
||||
}
|
||||
|
@ -14,12 +14,24 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
reloadOnSearch: false,
|
||||
pageClass: 'page-dashboard',
|
||||
})
|
||||
.when('/d/:uid/:slug', {
|
||||
templateUrl: 'public/app/partials/dashboard.html',
|
||||
controller: 'LoadDashboardCtrl',
|
||||
reloadOnSearch: false,
|
||||
pageClass: 'page-dashboard',
|
||||
})
|
||||
.when('/dashboard/:type/:slug', {
|
||||
templateUrl: 'public/app/partials/dashboard.html',
|
||||
controller: 'LoadDashboardCtrl',
|
||||
reloadOnSearch: false,
|
||||
pageClass: 'page-dashboard',
|
||||
})
|
||||
.when('/d-solo/:uid/:slug', {
|
||||
templateUrl: 'public/app/features/panel/partials/soloPanel.html',
|
||||
controller: 'SoloPanelCtrl',
|
||||
reloadOnSearch: false,
|
||||
pageClass: 'page-dashboard',
|
||||
})
|
||||
.when('/dashboard-solo/:type/:slug', {
|
||||
templateUrl: 'public/app/features/panel/partials/soloPanel.html',
|
||||
controller: 'SoloPanelCtrl',
|
||||
|
Loading…
Reference in New Issue
Block a user