mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Storage: support git + github backed roots (#52192)
This commit is contained in:
parent
e2044cde13
commit
197acd73c0
@ -5694,12 +5694,9 @@ exports[`better eslint`] = {
|
|||||||
"public/app/features/storage/storage.ts:5381": [
|
"public/app/features/storage/storage.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "3"],
|
[0, 0, 0, "Do not use any type assertions.", "3"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "4"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
|
|
||||||
[0, 0, 0, "Do not use any type assertions.", "7"]
|
|
||||||
],
|
],
|
||||||
"public/app/features/teams/CreateTeam.test.tsx:5381": [
|
"public/app/features/teams/CreateTeam.test.tsx:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||||
@ -8528,8 +8525,10 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
],
|
],
|
||||||
"public/app/plugins/panel/barchart/BarChartPanel.tsx:5381": [
|
"public/app/plugins/panel/barchart/BarChartPanel.tsx:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||||
|
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||||
|
[0, 0, 0, "Do not use any type assertions.", "3"]
|
||||||
],
|
],
|
||||||
"public/app/plugins/panel/barchart/TickSpacingEditor.tsx:5381": [
|
"public/app/plugins/panel/barchart/TickSpacingEditor.tsx:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||||
|
4
go.mod
4
go.mod
@ -42,7 +42,7 @@ require (
|
|||||||
github.com/go-sql-driver/mysql v1.6.0
|
github.com/go-sql-driver/mysql v1.6.0
|
||||||
github.com/go-stack/stack v1.8.0
|
github.com/go-stack/stack v1.8.0
|
||||||
github.com/gobwas/glob v0.2.3
|
github.com/gobwas/glob v0.2.3
|
||||||
github.com/gofrs/uuid v4.0.0+incompatible
|
github.com/gofrs/uuid v4.0.0+incompatible // indirect
|
||||||
github.com/gogo/protobuf v1.3.2
|
github.com/gogo/protobuf v1.3.2
|
||||||
github.com/golang/mock v1.6.0
|
github.com/golang/mock v1.6.0
|
||||||
github.com/golang/snappy v0.0.4
|
github.com/golang/snappy v0.0.4
|
||||||
@ -245,6 +245,7 @@ require (
|
|||||||
github.com/blugelabs/bluge_segment_api v0.2.0
|
github.com/blugelabs/bluge_segment_api v0.2.0
|
||||||
github.com/getkin/kin-openapi v0.94.0
|
github.com/getkin/kin-openapi v0.94.0
|
||||||
github.com/golang-migrate/migrate/v4 v4.7.0
|
github.com/golang-migrate/migrate/v4 v4.7.0
|
||||||
|
github.com/google/go-github/v45 v45.2.0
|
||||||
github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f
|
github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f
|
||||||
github.com/grafana/thema v0.0.0-20220726124731-b8017e278cc1
|
github.com/grafana/thema v0.0.0-20220726124731-b8017e278cc1
|
||||||
go.etcd.io/etcd/api/v3 v3.5.4
|
go.etcd.io/etcd/api/v3 v3.5.4
|
||||||
@ -258,6 +259,7 @@ require (
|
|||||||
cloud.google.com/go v0.100.2 // indirect
|
cloud.google.com/go v0.100.2 // indirect
|
||||||
github.com/armon/go-metrics v0.3.10 // indirect
|
github.com/armon/go-metrics v0.3.10 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/gofuzz v1.2.0 // indirect
|
github.com/google/gofuzz v1.2.0 // indirect
|
||||||
github.com/gosimple/unidecode v1.0.1 // indirect
|
github.com/gosimple/unidecode v1.0.1 // indirect
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
|
5
go.sum
5
go.sum
@ -1213,8 +1213,11 @@ github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8
|
|||||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
|
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
|
||||||
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
|
github.com/google/go-github/v45 v45.2.0 h1:5oRLszbrkvxDDqBCNj2hjDZMKmvexaZ1xw/FCD+K3FI=
|
||||||
|
github.com/google/go-github/v45 v45.2.0/go.mod h1:FObaZJEDSTa/WGCzZ2Z3eoCDXWJKMenWWTrd8jrta28=
|
||||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||||
|
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
|
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||||
github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk=
|
github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk=
|
||||||
github.com/google/go-replayers/httpreplay v1.1.1/go.mod h1:gN9GeLIs7l6NUoVaSSnv2RiqK1NiwAmD0MrKeC9IIks=
|
github.com/google/go-replayers/httpreplay v1.1.1/go.mod h1:gN9GeLIs7l6NUoVaSSnv2RiqK1NiwAmD0MrKeC9IIks=
|
||||||
github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
|
github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
|
||||||
|
@ -256,17 +256,7 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if hs.Features.IsEnabled(featuremgmt.FlagStorage) {
|
if hs.Features.IsEnabled(featuremgmt.FlagStorage) {
|
||||||
apiRoute.Group("/storage", func(storageRoute routing.RouteRegister) {
|
apiRoute.Group("/storage", hs.StorageService.RegisterHTTPRoutes)
|
||||||
storageRoute.Get("/list/", routing.Wrap(hs.StorageService.List))
|
|
||||||
storageRoute.Get("/list/*", routing.Wrap(hs.StorageService.List))
|
|
||||||
storageRoute.Get("/read/*", routing.Wrap(hs.StorageService.Read))
|
|
||||||
|
|
||||||
// Write paths
|
|
||||||
storageRoute.Post("/delete/*", reqGrafanaAdmin, routing.Wrap(hs.StorageService.Delete))
|
|
||||||
storageRoute.Post("/upload", reqGrafanaAdmin, routing.Wrap(hs.StorageService.Upload))
|
|
||||||
storageRoute.Post("/createFolder", reqGrafanaAdmin, routing.Wrap(hs.StorageService.CreateFolder))
|
|
||||||
storageRoute.Post("/deleteFolder", reqGrafanaAdmin, routing.Wrap(hs.StorageService.DeleteFolder))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// current org
|
// current org
|
||||||
|
@ -128,7 +128,7 @@ type HTTPServer struct {
|
|||||||
LivePushGateway *pushhttp.Gateway
|
LivePushGateway *pushhttp.Gateway
|
||||||
ThumbService thumbs.Service
|
ThumbService thumbs.Service
|
||||||
ExportService export.ExportService
|
ExportService export.ExportService
|
||||||
StorageService store.HTTPStorageService
|
StorageService store.StorageService
|
||||||
ContextHandler *contexthandler.ContextHandler
|
ContextHandler *contexthandler.ContextHandler
|
||||||
SQLStore sqlstore.Store
|
SQLStore sqlstore.Store
|
||||||
AlertEngine *alerting.AlertEngine
|
AlertEngine *alerting.AlertEngine
|
||||||
@ -202,7 +202,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
|||||||
pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service,
|
pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service,
|
||||||
dataSourcesService datasources.DataSourceService, secretsService secrets.Service, queryDataService *query.Service,
|
dataSourcesService datasources.DataSourceService, secretsService secrets.Service, queryDataService *query.Service,
|
||||||
ldapGroups ldap.Groups, teamGuardian teamguardian.TeamGuardian, serviceaccountsService serviceaccounts.Service,
|
ldapGroups ldap.Groups, teamGuardian teamguardian.TeamGuardian, serviceaccountsService serviceaccounts.Service,
|
||||||
authInfoService login.AuthInfoService, storageService store.HTTPStorageService,
|
authInfoService login.AuthInfoService, storageService store.StorageService,
|
||||||
notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService,
|
notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService,
|
||||||
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService,
|
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService,
|
||||||
datasourcePermissionsService permissions.DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService,
|
datasourcePermissionsService permissions.DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService,
|
||||||
|
@ -200,7 +200,6 @@ var wireBasicSet = wire.NewSet(
|
|||||||
search.ProvideService,
|
search.ProvideService,
|
||||||
searchV2.ProvideService,
|
searchV2.ProvideService,
|
||||||
store.ProvideService,
|
store.ProvideService,
|
||||||
store.ProvideHTTPService,
|
|
||||||
export.ProvideService,
|
export.ProvideService,
|
||||||
live.ProvideService,
|
live.ProvideService,
|
||||||
pushhttp.ProvideService,
|
pushhttp.ProvideService,
|
||||||
|
@ -136,9 +136,10 @@ var (
|
|||||||
State: FeatureStateAlpha,
|
State: FeatureStateAlpha,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "dashboardsFromStorage",
|
Name: "dashboardsFromStorage",
|
||||||
Description: "Load dashboards from the generic storage interface",
|
Description: "Load dashboards from the generic storage interface",
|
||||||
State: FeatureStateAlpha,
|
State: FeatureStateAlpha,
|
||||||
|
RequiresDevMode: true, // Also a gate on automatic git storage (for now)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "export",
|
Name: "export",
|
||||||
|
@ -1,10 +1,110 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
// For now this file is stored in $GRAFANA_HOME/conf/storage.json and updated from the UI
|
||||||
|
type GlobalStorageConfig struct {
|
||||||
|
filepath string // Local file path
|
||||||
|
|
||||||
|
// Defined in grafana.ini
|
||||||
|
AllowUnsanitizedSvgUpload bool `json:"allowUnsanitizedSvgUpload"`
|
||||||
|
|
||||||
|
// Add dev environment
|
||||||
|
AddDevEnv bool `json:"addDevEnv"`
|
||||||
|
|
||||||
|
// Paths under 'root' (NOTE: this is applied to all orgs)
|
||||||
|
Roots []RootStorageConfig `json:"roots"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadStorageConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (*GlobalStorageConfig, error) {
|
||||||
|
changed := false
|
||||||
|
fpath := filepath.Join(cfg.DataPath, "storage", "storage.json")
|
||||||
|
g := &GlobalStorageConfig{}
|
||||||
|
if _, err := os.Stat(fpath); err == nil {
|
||||||
|
// nolint:gosec
|
||||||
|
// We can ignore the gosec G304 warning since the path is hardcoded above
|
||||||
|
body, err := ioutil.ReadFile(fpath)
|
||||||
|
if err != nil {
|
||||||
|
return g, err
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(body, g)
|
||||||
|
if err != nil {
|
||||||
|
return g, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
g.AddDevEnv = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Roots == nil && features.IsEnabled(featuremgmt.FlagDashboardsFromStorage) {
|
||||||
|
g.Roots = append(g.Roots, RootStorageConfig{
|
||||||
|
Type: "git",
|
||||||
|
Prefix: "it-A",
|
||||||
|
Name: "Repository that requires pull requests",
|
||||||
|
Git: &StorageGitConfig{
|
||||||
|
Remote: "https://github.com/grafana/hackathon-2022-03-git-dash-A",
|
||||||
|
Branch: "main",
|
||||||
|
Root: "dashboards", // the dashboard files
|
||||||
|
RequirePullRequest: true,
|
||||||
|
AccessToken: "$GRAFANA_STORAGE_GITHUB_ACCESS_TOKEN",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
g.Roots = append(g.Roots, RootStorageConfig{
|
||||||
|
Type: "git",
|
||||||
|
Prefix: "it-B",
|
||||||
|
Name: "Another repo (can push to main)",
|
||||||
|
Git: &StorageGitConfig{
|
||||||
|
Remote: "https://github.com/grafana/hackathon-2022-03-git-dash-B",
|
||||||
|
Branch: "main",
|
||||||
|
Root: "dashboards", // the dashboard files
|
||||||
|
RequirePullRequest: false,
|
||||||
|
AccessToken: "$GRAFANA_STORAGE_GITHUB_ACCESS_TOKEN",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
g.filepath = fpath
|
||||||
|
|
||||||
|
// Also configured from ini files
|
||||||
|
if cfg.Storage.AllowUnsanitizedSvgUpload {
|
||||||
|
g.AllowUnsanitizedSvgUpload = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save a template version in config
|
||||||
|
if changed && setting.Env != setting.Prod {
|
||||||
|
return g, g.save()
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GlobalStorageConfig) save() error {
|
||||||
|
out, err := json.MarshalIndent(c, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = os.MkdirAll(filepath.Dir(c.filepath), 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ioutil.WriteFile(c.filepath, out, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
type RootStorageConfig struct {
|
type RootStorageConfig struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Prefix string `json:"prefix"`
|
Prefix string `json:"prefix"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
|
||||||
// Depending on type, these will be configured
|
// Depending on type, these will be configured
|
||||||
Disk *StorageLocalDiskConfig `json:"disk,omitempty"`
|
Disk *StorageLocalDiskConfig `json:"disk,omitempty"`
|
||||||
@ -26,7 +126,8 @@ type StorageGitConfig struct {
|
|||||||
|
|
||||||
// Pull interval?
|
// Pull interval?
|
||||||
// Requires pull request?
|
// Requires pull request?
|
||||||
RequirePullRequest bool `json:"requirePullRequest"`
|
RequirePullRequest bool `json:"requirePullRequest"`
|
||||||
|
PullInterval string `json:"pullInterval"`
|
||||||
|
|
||||||
// SECURE JSON :grimicing:
|
// SECURE JSON :grimicing:
|
||||||
AccessToken string `json:"accessToken,omitempty"` // Simplest auth method for github
|
AccessToken string `json:"accessToken,omitempty"` // Simplest auth method for github
|
||||||
@ -52,3 +153,14 @@ type StorageGCSConfig struct {
|
|||||||
|
|
||||||
CredentialsFile string `json:"credentialsFile"`
|
CredentialsFile string `json:"credentialsFile"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newStorage(cfg RootStorageConfig, localWorkCache string) (storageRuntime, error) {
|
||||||
|
switch cfg.Type {
|
||||||
|
case rootStorageTypeDisk:
|
||||||
|
return newDiskStorage(RootStorageMeta{}, cfg), nil
|
||||||
|
case rootStorageTypeGit:
|
||||||
|
return newGitStorage(RootStorageMeta{}, cfg, localWorkCache), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unsupported store: " + cfg.Type)
|
||||||
|
}
|
||||||
|
182
pkg/services/store/github_helper.go
Normal file
182
pkg/services/store/github_helper.go
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/go-github/v45/github"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type githubHelper struct {
|
||||||
|
repoOwner string
|
||||||
|
repoName string
|
||||||
|
client *github.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGithubHelper(ctx context.Context, uri string, token string) (*githubHelper, error) {
|
||||||
|
v, err := url.Parse(uri)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(v.Path, "/")
|
||||||
|
path = strings.TrimSuffix(path, ".git")
|
||||||
|
idx := strings.Index(path, "/")
|
||||||
|
if idx < 1 {
|
||||||
|
return nil, fmt.Errorf("invalid url")
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("unauthorized: No token present")
|
||||||
|
}
|
||||||
|
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
|
||||||
|
tc := oauth2.NewClient(ctx, ts)
|
||||||
|
return &githubHelper{
|
||||||
|
client: github.NewClient(tc),
|
||||||
|
repoOwner: path[:idx],
|
||||||
|
repoName: path[idx+1:],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *githubHelper) getRef(ctx context.Context, branch string) (*github.Reference, *github.Response, error) {
|
||||||
|
return g.client.Git.GetRef(ctx, g.repoOwner, g.repoName, "refs/heads/"+branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *githubHelper) createRef(ctx context.Context, base string, branch string) (ref *github.Reference, rsp *github.Response, err error) {
|
||||||
|
var baseRef *github.Reference
|
||||||
|
if baseRef, rsp, err = g.client.Git.GetRef(ctx, g.repoOwner, g.repoName, "refs/heads/"+base); err != nil {
|
||||||
|
return nil, rsp, err
|
||||||
|
}
|
||||||
|
newRef := &github.Reference{
|
||||||
|
Ref: github.String("refs/heads/" + branch),
|
||||||
|
Object: &github.GitObject{SHA: baseRef.Object.SHA},
|
||||||
|
}
|
||||||
|
return g.client.Git.CreateRef(ctx, g.repoOwner, g.repoName, newRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *githubHelper) getRepo(ctx context.Context) (*github.Repository, *github.Response, error) {
|
||||||
|
return g.client.Repositories.Get(ctx, g.repoOwner, g.repoName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushCommit creates the commit in the given reference using the given tree.
|
||||||
|
func (g *githubHelper) pushCommit(ctx context.Context, ref *github.Reference, cmd *WriteValueRequest) (err error) {
|
||||||
|
// Create a tree with what to commit.
|
||||||
|
entries := []*github.TreeEntry{
|
||||||
|
{
|
||||||
|
Path: github.String(cmd.Path),
|
||||||
|
Type: github.String("blob"),
|
||||||
|
Content: github.String(string(cmd.Body)),
|
||||||
|
Mode: github.String("100644"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tree, _, err := g.client.Git.CreateTree(ctx, g.repoOwner, g.repoName, *ref.Object.SHA, entries)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the parent commit to attach the commit to.
|
||||||
|
parent, _, err := g.client.Repositories.GetCommit(ctx, g.repoOwner, g.repoName, *ref.Object.SHA, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is not always populated, but is needed.
|
||||||
|
parent.Commit.SHA = parent.SHA
|
||||||
|
|
||||||
|
user := cmd.User
|
||||||
|
name := firstRealString(user.Name, user.Login, user.Email, "?")
|
||||||
|
email := firstRealString(user.Email, user.Login, user.Name, "?")
|
||||||
|
|
||||||
|
// Create the commit using the tree.
|
||||||
|
date := time.Now()
|
||||||
|
author := &github.CommitAuthor{
|
||||||
|
Date: &date,
|
||||||
|
Name: &name,
|
||||||
|
Email: &email,
|
||||||
|
}
|
||||||
|
commit := &github.Commit{Author: author, Message: &cmd.Message, Tree: tree, Parents: []*github.Commit{parent.Commit}}
|
||||||
|
newCommit, _, err := g.client.Git.CreateCommit(ctx, g.repoOwner, g.repoName, commit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach the commit to the main branch.
|
||||||
|
ref.Object.SHA = newCommit.SHA
|
||||||
|
_, _, err = g.client.Git.UpdateRef(ctx, g.repoOwner, g.repoName, ref, false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type makePRCommand struct {
|
||||||
|
title string
|
||||||
|
body string
|
||||||
|
headBranch string
|
||||||
|
baseBranch string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *githubHelper) createPR(ctx context.Context, cmd makePRCommand) (*github.PullRequest, *github.Response, error) {
|
||||||
|
newPR := &github.NewPullRequest{
|
||||||
|
Title: &cmd.title,
|
||||||
|
Head: &cmd.headBranch,
|
||||||
|
Base: &cmd.baseBranch,
|
||||||
|
Body: &cmd.body,
|
||||||
|
MaintainerCanModify: github.Bool(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
return g.client.PullRequests.Create(ctx, g.repoOwner, g.repoName, newPR)
|
||||||
|
}
|
||||||
|
|
||||||
|
// func (g *githubHelper) getPR(config *Config, prSubject string) (*github.PullRequest, error) {
|
||||||
|
|
||||||
|
// opts := github.PullRequestListOptions{}
|
||||||
|
|
||||||
|
// prs, _, err := githubClient.PullRequests.List(ctx, config.RepoOwner, config.RepoName, &opts)
|
||||||
|
// if err != nil {
|
||||||
|
// return nil, err
|
||||||
|
// }
|
||||||
|
// for _, pr := range prs {
|
||||||
|
// log.Printf("PR: %s %s", *pr.Title, prSubject)
|
||||||
|
// if *pr.Title == prSubject {
|
||||||
|
// return pr, nil
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return nil, nil
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func (g *githubHelper) pushPR(config *Config, prSubject, prBranch, prFilename, prContent, commitMessage string) error {
|
||||||
|
// pr, err := getPR(config, prSubject)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// if pr != nil {
|
||||||
|
// log.Println("Extending Existing PR", *pr.Title)
|
||||||
|
// ref, err := getRef(config, pr.GetHead().GetRef())
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// err = pushCommit(config, ref, prFilename, prContent, commitMessage)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
|
||||||
|
// } else {
|
||||||
|
// log.Println("Creating PR")
|
||||||
|
// ref, err := createRef(config, prBranch)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// err = pushCommit(config, ref, prFilename, prContent, commitMessage)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// pr, err = createPR(config, prSubject, prBranch)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return nil
|
||||||
|
// }
|
@ -10,34 +10,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/response"
|
"github.com/grafana/grafana/pkg/api/response"
|
||||||
|
"github.com/grafana/grafana/pkg/api/routing"
|
||||||
|
"github.com/grafana/grafana/pkg/middleware"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/quota"
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
"github.com/grafana/grafana/pkg/web"
|
"github.com/grafana/grafana/pkg/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTPStorageService passes raw HTTP requests to a well typed storage service
|
|
||||||
type HTTPStorageService interface {
|
|
||||||
List(c *models.ReqContext) response.Response
|
|
||||||
Read(c *models.ReqContext) response.Response
|
|
||||||
Delete(c *models.ReqContext) response.Response
|
|
||||||
DeleteFolder(c *models.ReqContext) response.Response
|
|
||||||
CreateFolder(c *models.ReqContext) response.Response
|
|
||||||
Upload(c *models.ReqContext) response.Response
|
|
||||||
}
|
|
||||||
|
|
||||||
type httpStorage struct {
|
|
||||||
store StorageService
|
|
||||||
quotaService quota.Service
|
|
||||||
}
|
|
||||||
|
|
||||||
func ProvideHTTPService(store StorageService, quotaService quota.Service) HTTPStorageService {
|
|
||||||
return &httpStorage{
|
|
||||||
store: store,
|
|
||||||
quotaService: quotaService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func UploadErrorToStatusCode(err error) int {
|
func UploadErrorToStatusCode(err error) int {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrStorageNotFound):
|
case errors.Is(err, ErrStorageNotFound):
|
||||||
@ -60,7 +40,37 @@ func UploadErrorToStatusCode(err error) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
|
func (s *standardStorageService) RegisterHTTPRoutes(storageRoute routing.RouteRegister) {
|
||||||
|
storageRoute.Get("/list/", routing.Wrap(s.list))
|
||||||
|
storageRoute.Get("/list/*", routing.Wrap(s.list))
|
||||||
|
storageRoute.Get("/read/*", routing.Wrap(s.read))
|
||||||
|
storageRoute.Get("/options/*", routing.Wrap(s.getOptions))
|
||||||
|
|
||||||
|
// Write paths
|
||||||
|
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
|
||||||
|
storageRoute.Post("/write/*", reqGrafanaAdmin, routing.Wrap(s.doWrite))
|
||||||
|
storageRoute.Post("/delete/*", reqGrafanaAdmin, routing.Wrap(s.doDelete))
|
||||||
|
storageRoute.Post("/upload", reqGrafanaAdmin, routing.Wrap(s.doUpload))
|
||||||
|
storageRoute.Post("/createFolder", reqGrafanaAdmin, routing.Wrap(s.doCreateFolder))
|
||||||
|
storageRoute.Post("/deleteFolder", reqGrafanaAdmin, routing.Wrap(s.doDeleteFolder))
|
||||||
|
storageRoute.Get("/config", reqGrafanaAdmin, routing.Wrap(s.getConfig))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *standardStorageService) doWrite(c *models.ReqContext) response.Response {
|
||||||
|
scope, path := getPathAndScope(c)
|
||||||
|
cmd := &WriteValueRequest{}
|
||||||
|
if err := web.Bind(c.Req, cmd); err != nil {
|
||||||
|
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||||
|
}
|
||||||
|
cmd.Path = scope + "/" + path
|
||||||
|
rsp, err := s.write(c.Req.Context(), c.SignedInUser, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return response.Error(http.StatusBadRequest, "save error", err)
|
||||||
|
}
|
||||||
|
return response.JSON(200, rsp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *standardStorageService) doUpload(c *models.ReqContext) response.Response {
|
||||||
// assumes we are only uploading to the SQL database - TODO: refactor once we introduce object stores
|
// assumes we are only uploading to the SQL database - TODO: refactor once we introduce object stores
|
||||||
quotaReached, err := s.quotaService.CheckQuotaReached(c.Req.Context(), "file", nil)
|
quotaReached, err := s.quotaService.CheckQuotaReached(c.Req.Context(), "file", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -127,7 +137,7 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
|
|||||||
entityType = EntityTypeImage
|
entityType = EntityTypeImage
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.store.Upload(c.Req.Context(), c.SignedInUser, &UploadRequest{
|
err = s.Upload(c.Req.Context(), c.SignedInUser, &UploadRequest{
|
||||||
Contents: data,
|
Contents: data,
|
||||||
EntityType: entityType,
|
EntityType: entityType,
|
||||||
Path: path,
|
Path: path,
|
||||||
@ -157,10 +167,10 @@ func getMultipartFormValue(req *http.Request, key string) string {
|
|||||||
return v[0]
|
return v[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *httpStorage) Read(c *models.ReqContext) response.Response {
|
func (s *standardStorageService) read(c *models.ReqContext) response.Response {
|
||||||
// full path is api/storage/read/upload/example.jpg, but we only want the part after read
|
// full path is api/storage/read/upload/example.jpg, but we only want the part after read
|
||||||
scope, path := getPathAndScope(c)
|
scope, path := getPathAndScope(c)
|
||||||
file, err := s.store.Read(c.Req.Context(), c.SignedInUser, scope+"/"+path)
|
file, err := s.Read(c.Req.Context(), c.SignedInUser, scope+"/"+path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.Error(400, "cannot call read", err)
|
return response.Error(400, "cannot call read", err)
|
||||||
}
|
}
|
||||||
@ -176,11 +186,20 @@ func (s *httpStorage) Read(c *models.ReqContext) response.Response {
|
|||||||
return response.Respond(200, file.Contents)
|
return response.Respond(200, file.Contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *httpStorage) Delete(c *models.ReqContext) response.Response {
|
func (s *standardStorageService) getOptions(c *models.ReqContext) response.Response {
|
||||||
|
scope, path := getPathAndScope(c)
|
||||||
|
opts, err := s.getWorkflowOptions(c.Req.Context(), c.SignedInUser, scope+"/"+path)
|
||||||
|
if err != nil {
|
||||||
|
return response.Error(400, err.Error(), err)
|
||||||
|
}
|
||||||
|
return response.JSON(200, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *standardStorageService) doDelete(c *models.ReqContext) response.Response {
|
||||||
// full path is api/storage/delete/upload/example.jpg, but we only want the part after upload
|
// full path is api/storage/delete/upload/example.jpg, but we only want the part after upload
|
||||||
scope, path := getPathAndScope(c)
|
scope, path := getPathAndScope(c)
|
||||||
|
|
||||||
err := s.store.Delete(c.Req.Context(), c.SignedInUser, scope+"/"+path)
|
err := s.Delete(c.Req.Context(), c.SignedInUser, scope+"/"+path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.Error(400, "failed to delete the file: "+err.Error(), err)
|
return response.Error(400, "failed to delete the file: "+err.Error(), err)
|
||||||
}
|
}
|
||||||
@ -191,7 +210,7 @@ func (s *httpStorage) Delete(c *models.ReqContext) response.Response {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *httpStorage) DeleteFolder(c *models.ReqContext) response.Response {
|
func (s *standardStorageService) doDeleteFolder(c *models.ReqContext) response.Response {
|
||||||
body, err := io.ReadAll(c.Req.Body)
|
body, err := io.ReadAll(c.Req.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.Error(500, "error reading bytes", err)
|
return response.Error(500, "error reading bytes", err)
|
||||||
@ -209,7 +228,7 @@ func (s *httpStorage) DeleteFolder(c *models.ReqContext) response.Response {
|
|||||||
|
|
||||||
// full path is api/storage/delete/upload/example.jpg, but we only want the part after upload
|
// full path is api/storage/delete/upload/example.jpg, but we only want the part after upload
|
||||||
_, path := getPathAndScope(c)
|
_, path := getPathAndScope(c)
|
||||||
if err := s.store.DeleteFolder(c.Req.Context(), c.SignedInUser, cmd); err != nil {
|
if err := s.DeleteFolder(c.Req.Context(), c.SignedInUser, cmd); err != nil {
|
||||||
return response.Error(400, "failed to delete the folder: "+err.Error(), err)
|
return response.Error(400, "failed to delete the folder: "+err.Error(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,7 +239,7 @@ func (s *httpStorage) DeleteFolder(c *models.ReqContext) response.Response {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *httpStorage) CreateFolder(c *models.ReqContext) response.Response {
|
func (s *standardStorageService) doCreateFolder(c *models.ReqContext) response.Response {
|
||||||
body, err := io.ReadAll(c.Req.Body)
|
body, err := io.ReadAll(c.Req.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.Error(500, "error reading bytes", err)
|
return response.Error(500, "error reading bytes", err)
|
||||||
@ -236,7 +255,7 @@ func (s *httpStorage) CreateFolder(c *models.ReqContext) response.Response {
|
|||||||
return response.Error(400, "empty path", err)
|
return response.Error(400, "empty path", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.CreateFolder(c.Req.Context(), c.SignedInUser, cmd); err != nil {
|
if err := s.CreateFolder(c.Req.Context(), c.SignedInUser, cmd); err != nil {
|
||||||
return response.Error(400, "failed to create the folder: "+err.Error(), err)
|
return response.Error(400, "failed to create the folder: "+err.Error(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,10 +266,10 @@ func (s *httpStorage) CreateFolder(c *models.ReqContext) response.Response {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *httpStorage) List(c *models.ReqContext) response.Response {
|
func (s *standardStorageService) list(c *models.ReqContext) response.Response {
|
||||||
params := web.Params(c.Req)
|
params := web.Params(c.Req)
|
||||||
path := params["*"]
|
path := params["*"]
|
||||||
frame, err := s.store.List(c.Req.Context(), c.SignedInUser, path)
|
frame, err := s.List(c.Req.Context(), c.SignedInUser, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.Error(400, "error reading path", err)
|
return response.Error(400, "error reading path", err)
|
||||||
}
|
}
|
||||||
@ -259,3 +278,19 @@ func (s *httpStorage) List(c *models.ReqContext) response.Response {
|
|||||||
}
|
}
|
||||||
return response.JSONStreaming(http.StatusOK, frame)
|
return response.JSONStreaming(http.StatusOK, frame)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *standardStorageService) getConfig(c *models.ReqContext) response.Response {
|
||||||
|
roots := make([]RootStorageMeta, 0)
|
||||||
|
orgId := c.OrgId
|
||||||
|
t := s.tree
|
||||||
|
t.assureOrgIsInitialized(orgId)
|
||||||
|
for _, f := range t.rootsByOrgId[ac.GlobalOrgID] {
|
||||||
|
roots = append(roots, f.Meta())
|
||||||
|
}
|
||||||
|
if orgId != ac.GlobalOrgID {
|
||||||
|
for _, f := range t.rootsByOrgId[orgId] {
|
||||||
|
roots = append(roots, f.Meta())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response.JSON(200, roots)
|
||||||
|
}
|
||||||
|
@ -20,7 +20,7 @@ func (s *standardStorageService) sanitizeContents(ctx context.Context, user *mod
|
|||||||
Content: req.Contents,
|
Content: req.Contents,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s.cfg.allowUnsanitizedSvgUpload {
|
if s.cfg != nil && s.cfg.AllowUnsanitizedSvgUpload {
|
||||||
grafanaStorageLogger.Debug("allowing unsanitized svg upload", "filename", req.Path, "sanitizationError", err)
|
grafanaStorageLogger.Debug("allowing unsanitized svg upload", "filename", req.Path, "sanitizationError", err)
|
||||||
return req.Contents, nil
|
return req.Contents, nil
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,17 +1,22 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/api/routing"
|
||||||
"github.com/grafana/grafana/pkg/infra/filestorage"
|
"github.com/grafana/grafana/pkg/infra/filestorage"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/registry"
|
"github.com/grafana/grafana/pkg/registry"
|
||||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
|
"github.com/grafana/grafana/pkg/services/quota"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
)
|
)
|
||||||
@ -24,6 +29,7 @@ var ErrValidationFailed = errors.New("request validation failed")
|
|||||||
var ErrFileAlreadyExists = errors.New("file exists")
|
var ErrFileAlreadyExists = errors.New("file exists")
|
||||||
var ErrStorageNotFound = errors.New("storage not found")
|
var ErrStorageNotFound = errors.New("storage not found")
|
||||||
var ErrAccessDenied = errors.New("access denied")
|
var ErrAccessDenied = errors.New("access denied")
|
||||||
|
var ErrOnlyDashboardSaveSupported = errors.New("only dashboard save is currently supported")
|
||||||
|
|
||||||
const RootPublicStatic = "public-static"
|
const RootPublicStatic = "public-static"
|
||||||
const RootResources = "resources"
|
const RootResources = "resources"
|
||||||
@ -52,6 +58,9 @@ type CreateFolderCmd struct {
|
|||||||
type StorageService interface {
|
type StorageService interface {
|
||||||
registry.BackgroundService
|
registry.BackgroundService
|
||||||
|
|
||||||
|
// Register the HTTP
|
||||||
|
RegisterHTTPRoutes(routing.RouteRegister)
|
||||||
|
|
||||||
// List folder contents
|
// List folder contents
|
||||||
List(ctx context.Context, user *models.SignedInUser, path string) (*StorageListFrame, error)
|
List(ctx context.Context, user *models.SignedInUser, path string) (*StorageListFrame, error)
|
||||||
|
|
||||||
@ -72,20 +81,31 @@ type StorageService interface {
|
|||||||
sanitizeUploadRequest(ctx context.Context, user *models.SignedInUser, req *UploadRequest, storagePath string) (*filestorage.UpsertFileCommand, error)
|
sanitizeUploadRequest(ctx context.Context, user *models.SignedInUser, req *UploadRequest, storagePath string) (*filestorage.UpsertFileCommand, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type storageServiceConfig struct {
|
|
||||||
allowUnsanitizedSvgUpload bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type standardStorageService struct {
|
type standardStorageService struct {
|
||||||
sql *sqlstore.SQLStore
|
sql *sqlstore.SQLStore
|
||||||
tree *nestedTree
|
tree *nestedTree
|
||||||
cfg storageServiceConfig
|
cfg *GlobalStorageConfig
|
||||||
authService storageAuthService
|
authService storageAuthService
|
||||||
|
quotaService quota.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles, cfg *setting.Cfg) StorageService {
|
func ProvideService(
|
||||||
|
sql *sqlstore.SQLStore,
|
||||||
|
features featuremgmt.FeatureToggles,
|
||||||
|
cfg *setting.Cfg,
|
||||||
|
quotaService quota.Service,
|
||||||
|
) StorageService {
|
||||||
|
settings, err := LoadStorageConfig(cfg, features)
|
||||||
|
if err != nil {
|
||||||
|
grafanaStorageLogger.Warn("error loading storage config", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// always exists
|
||||||
globalRoots := []storageRuntime{
|
globalRoots := []storageRuntime{
|
||||||
newDiskStorage(RootStorageConfig{
|
newDiskStorage(RootStorageMeta{
|
||||||
|
ReadOnly: true,
|
||||||
|
Builtin: true,
|
||||||
|
}, RootStorageConfig{
|
||||||
Prefix: RootPublicStatic,
|
Prefix: RootPublicStatic,
|
||||||
Name: "Public static files",
|
Name: "Public static files",
|
||||||
Description: "Access files from the static public files",
|
Description: "Access files from the static public files",
|
||||||
@ -98,14 +118,16 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
|
|||||||
"/maps/",
|
"/maps/",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}).setReadOnly(true).setBuiltin(true),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Development dashboards
|
// Development dashboards
|
||||||
if setting.Env != setting.Prod {
|
if settings.AddDevEnv && setting.Env != setting.Prod {
|
||||||
devenv := filepath.Join(cfg.StaticRootPath, "..", "devenv")
|
devenv := filepath.Join(cfg.StaticRootPath, "..", "devenv")
|
||||||
if _, err := os.Stat(devenv); !os.IsNotExist(err) {
|
if _, err := os.Stat(devenv); !os.IsNotExist(err) {
|
||||||
s := newDiskStorage(RootStorageConfig{
|
s := newDiskStorage(RootStorageMeta{
|
||||||
|
ReadOnly: false,
|
||||||
|
}, RootStorageConfig{
|
||||||
Prefix: RootDevenv,
|
Prefix: RootDevenv,
|
||||||
Name: "Development Environment",
|
Name: "Development Environment",
|
||||||
Description: "Explore files within the developer environment directly",
|
Description: "Explore files within the developer environment directly",
|
||||||
@ -114,8 +136,21 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
|
|||||||
Roots: []string{
|
Roots: []string{
|
||||||
"/dev-dashboards/",
|
"/dev-dashboards/",
|
||||||
},
|
},
|
||||||
}}).setReadOnly(false)
|
}})
|
||||||
|
globalRoots = append(globalRoots, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, root := range settings.Roots {
|
||||||
|
if root.Prefix == "" {
|
||||||
|
grafanaStorageLogger.Warn("Invalid root configuration", "cfg", root)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, err := newStorage(root, filepath.Join(cfg.DataPath, "storage", "cache", root.Prefix))
|
||||||
|
if err != nil {
|
||||||
|
grafanaStorageLogger.Warn("error loading storage config", "error", err)
|
||||||
|
}
|
||||||
|
if s != nil {
|
||||||
globalRoots = append(globalRoots, s)
|
globalRoots = append(globalRoots, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -125,19 +160,21 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
|
|||||||
|
|
||||||
// Custom upload files
|
// Custom upload files
|
||||||
storages = append(storages,
|
storages = append(storages,
|
||||||
newSQLStorage(RootResources,
|
newSQLStorage(RootStorageMeta{
|
||||||
|
Builtin: true,
|
||||||
|
}, RootResources,
|
||||||
"Resources",
|
"Resources",
|
||||||
"Upload custom resource files",
|
"Upload custom resource files",
|
||||||
&StorageSQLConfig{}, sql, orgId).
|
&StorageSQLConfig{}, sql, orgId))
|
||||||
setBuiltin(true))
|
|
||||||
|
|
||||||
// System settings
|
// System settings
|
||||||
storages = append(storages,
|
storages = append(storages,
|
||||||
newSQLStorage(RootSystem,
|
newSQLStorage(RootStorageMeta{
|
||||||
|
Builtin: true,
|
||||||
|
}, RootResources,
|
||||||
"System",
|
"System",
|
||||||
"Grafana system storage",
|
"Grafana system storage",
|
||||||
&StorageSQLConfig{}, sql, orgId).
|
&StorageSQLConfig{}, sql, orgId))
|
||||||
setBuiltin(true))
|
|
||||||
|
|
||||||
return storages
|
return storages
|
||||||
}
|
}
|
||||||
@ -179,25 +216,18 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch storageName {
|
// Admin can do anything
|
||||||
case RootDevenv:
|
return map[string]filestorage.PathFilter{
|
||||||
return map[string]filestorage.PathFilter{
|
ActionFilesRead: allowAllPathFilter,
|
||||||
ActionFilesRead: allowAllPathFilter,
|
ActionFilesWrite: allowAllPathFilter,
|
||||||
ActionFilesWrite: denyAllPathFilter,
|
ActionFilesDelete: allowAllPathFilter,
|
||||||
ActionFilesDelete: denyAllPathFilter,
|
|
||||||
}
|
|
||||||
case RootResources:
|
|
||||||
return map[string]filestorage.PathFilter{
|
|
||||||
ActionFilesRead: allowAllPathFilter,
|
|
||||||
ActionFilesWrite: allowAllPathFilter,
|
|
||||||
ActionFilesDelete: allowAllPathFilter,
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return newStandardStorageService(sql, globalRoots, initializeOrgStorages, authService, cfg)
|
s := newStandardStorageService(sql, globalRoots, initializeOrgStorages, authService, cfg)
|
||||||
|
s.quotaService = quotaService
|
||||||
|
s.cfg = settings
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func createSystemBrandingPathFilter() filestorage.PathFilter {
|
func createSystemBrandingPathFilter() filestorage.PathFilter {
|
||||||
@ -208,7 +238,13 @@ func createSystemBrandingPathFilter() filestorage.PathFilter {
|
|||||||
nil)
|
nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStandardStorageService(sql *sqlstore.SQLStore, globalRoots []storageRuntime, initializeOrgStorages func(orgId int64) []storageRuntime, authService storageAuthService, cfg *setting.Cfg) *standardStorageService {
|
func newStandardStorageService(
|
||||||
|
sql *sqlstore.SQLStore,
|
||||||
|
globalRoots []storageRuntime,
|
||||||
|
initializeOrgStorages func(orgId int64) []storageRuntime,
|
||||||
|
authService storageAuthService,
|
||||||
|
cfg *setting.Cfg,
|
||||||
|
) *standardStorageService {
|
||||||
rootsByOrgId := make(map[int64][]storageRuntime)
|
rootsByOrgId := make(map[int64][]storageRuntime)
|
||||||
rootsByOrgId[ac.GlobalOrgID] = globalRoots
|
rootsByOrgId[ac.GlobalOrgID] = globalRoots
|
||||||
|
|
||||||
@ -221,9 +257,6 @@ func newStandardStorageService(sql *sqlstore.SQLStore, globalRoots []storageRunt
|
|||||||
sql: sql,
|
sql: sql,
|
||||||
tree: res,
|
tree: res,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
cfg: storageServiceConfig{
|
|
||||||
allowUnsanitizedSvgUpload: cfg.Storage.AllowUnsanitizedSvgUpload,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,3 +410,86 @@ func (s *standardStorageService) Delete(ctx context.Context, user *models.Signed
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *standardStorageService) write(ctx context.Context, user *models.SignedInUser, req *WriteValueRequest) (*WriteValueResponse, error) {
|
||||||
|
guardian := s.authService.newGuardian(ctx, user, getFirstSegment(req.Path))
|
||||||
|
if !guardian.canWrite(req.Path) {
|
||||||
|
return nil, ErrAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
root, storagePath := s.tree.getRoot(getOrgId(user), req.Path)
|
||||||
|
if root == nil {
|
||||||
|
return nil, ErrStorageNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.Meta().ReadOnly {
|
||||||
|
return nil, ErrUnsupportedStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
// not svg!
|
||||||
|
if req.EntityType != EntityTypeDashboard {
|
||||||
|
return nil, ErrOnlyDashboardSaveSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save pretty JSON
|
||||||
|
var prettyJSON bytes.Buffer
|
||||||
|
if err := json.Indent(&prettyJSON, req.Body, "", " "); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Body = prettyJSON.Bytes()
|
||||||
|
|
||||||
|
// Modify the save request
|
||||||
|
req.Path = storagePath
|
||||||
|
req.User = user
|
||||||
|
return root.Write(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
type workflowInfo struct {
|
||||||
|
Type WriteValueWorkflow `json:"value"` // value matches selectable value
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
type optionInfo struct {
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Workflows []workflowInfo `json:"workflows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *standardStorageService) getWorkflowOptions(ctx context.Context, user *models.SignedInUser, path string) (optionInfo, error) {
|
||||||
|
options := optionInfo{
|
||||||
|
Path: path,
|
||||||
|
Workflows: make([]workflowInfo, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
scope, _ := splitFirstSegment(path)
|
||||||
|
root, _ := s.tree.getRoot(user.OrgId, scope)
|
||||||
|
if root == nil {
|
||||||
|
return options, fmt.Errorf("can not read")
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := root.Meta()
|
||||||
|
if meta.Config.Type == rootStorageTypeGit && meta.Config.Git != nil {
|
||||||
|
cfg := meta.Config.Git
|
||||||
|
options.Workflows = append(options.Workflows, workflowInfo{
|
||||||
|
Type: WriteValueWorkflow_PR,
|
||||||
|
Label: "Create pull request",
|
||||||
|
Description: "Create a new upstream pull request",
|
||||||
|
})
|
||||||
|
if !cfg.RequirePullRequest {
|
||||||
|
options.Workflows = append(options.Workflows, workflowInfo{
|
||||||
|
Type: WriteValueWorkflow_Push,
|
||||||
|
Label: "Push to " + cfg.Branch,
|
||||||
|
Description: "Push commit to upstrem repository",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if meta.ReadOnly {
|
||||||
|
// nothing?
|
||||||
|
} else {
|
||||||
|
options.Workflows = append(options.Workflows, workflowInfo{
|
||||||
|
Type: WriteValueWorkflow_Save,
|
||||||
|
Label: "Save",
|
||||||
|
Description: "Save directly",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
|
@ -42,20 +42,24 @@ var (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
publicRoot, _ = filepath.Abs("../../../public")
|
publicRoot, _ = filepath.Abs("../../../public")
|
||||||
publicStaticFilesStorage = newDiskStorage(RootStorageConfig{
|
publicStaticFilesStorage = newDiskStorage(
|
||||||
Prefix: "public",
|
RootStorageMeta{
|
||||||
Name: "Public static files",
|
Builtin: true,
|
||||||
Disk: &StorageLocalDiskConfig{
|
ReadOnly: true,
|
||||||
Path: publicRoot,
|
}, RootStorageConfig{
|
||||||
Roots: []string{
|
Prefix: "public",
|
||||||
"/testdata/",
|
Name: "Public static files",
|
||||||
"/img/icons/",
|
Disk: &StorageLocalDiskConfig{
|
||||||
"/img/bg/",
|
Path: publicRoot,
|
||||||
"/gazetteer/",
|
Roots: []string{
|
||||||
"/maps/",
|
"/testdata/",
|
||||||
"/upload/",
|
"/img/icons/",
|
||||||
},
|
"/img/bg/",
|
||||||
}}).setReadOnly(true).setBuiltin(true)
|
"/gazetteer/",
|
||||||
|
"/maps/",
|
||||||
|
"/upload/",
|
||||||
|
},
|
||||||
|
}})
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestListFiles(t *testing.T) {
|
func TestListFiles(t *testing.T) {
|
||||||
@ -96,6 +100,7 @@ func setupUploadStore(t *testing.T, authService storageAuthService) (StorageServ
|
|||||||
storageName := "resources"
|
storageName := "resources"
|
||||||
mockStorage := &filestorage.MockFileStorage{}
|
mockStorage := &filestorage.MockFileStorage{}
|
||||||
sqlStorage := newSQLStorage(
|
sqlStorage := newSQLStorage(
|
||||||
|
RootStorageMeta{},
|
||||||
storageName, "Testing upload", "dummy descr",
|
storageName, "Testing upload", "dummy descr",
|
||||||
&StorageSQLConfig{},
|
&StorageSQLConfig{},
|
||||||
sqlstore.InitTestDB(t),
|
sqlstore.InitTestDB(t),
|
||||||
@ -109,6 +114,9 @@ func setupUploadStore(t *testing.T, authService storageAuthService) (StorageServ
|
|||||||
store := newStandardStorageService(sqlstore.InitTestDB(t), []storageRuntime{sqlStorage}, func(orgId int64) []storageRuntime {
|
store := newStandardStorageService(sqlstore.InitTestDB(t), []storageRuntime{sqlStorage}, func(orgId int64) []storageRuntime {
|
||||||
return make([]storageRuntime, 0)
|
return make([]storageRuntime, 0)
|
||||||
}, authService, cfg)
|
}, authService, cfg)
|
||||||
|
store.cfg = &GlobalStorageConfig{
|
||||||
|
AllowUnsanitizedSvgUpload: true,
|
||||||
|
}
|
||||||
|
|
||||||
return store, mockStorage, storageName
|
return store, mockStorage, storageName
|
||||||
}
|
}
|
||||||
|
@ -12,23 +12,22 @@ import (
|
|||||||
|
|
||||||
const rootStorageTypeDisk = "disk"
|
const rootStorageTypeDisk = "disk"
|
||||||
|
|
||||||
type rootStorageDisk struct {
|
var _ storageRuntime = &rootStorageDisk{}
|
||||||
baseStorageRuntime
|
|
||||||
|
|
||||||
|
type rootStorageDisk struct {
|
||||||
settings *StorageLocalDiskConfig
|
settings *StorageLocalDiskConfig
|
||||||
|
meta RootStorageMeta
|
||||||
|
store filestorage.FileStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDiskStorage(scfg RootStorageConfig) *rootStorageDisk {
|
func newDiskStorage(meta RootStorageMeta, scfg RootStorageConfig) *rootStorageDisk {
|
||||||
cfg := scfg.Disk
|
cfg := scfg.Disk
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
cfg = &StorageLocalDiskConfig{}
|
cfg = &StorageLocalDiskConfig{}
|
||||||
scfg.Disk = cfg
|
scfg.Disk = cfg
|
||||||
}
|
}
|
||||||
scfg.Type = rootStorageTypeDisk
|
scfg.Type = rootStorageTypeDisk
|
||||||
|
meta.Config = scfg
|
||||||
meta := RootStorageMeta{
|
|
||||||
Config: scfg,
|
|
||||||
}
|
|
||||||
if scfg.Prefix == "" {
|
if scfg.Prefix == "" {
|
||||||
meta.Notice = append(meta.Notice, data.Notice{
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
Severity: data.NoticeSeverityError,
|
Severity: data.NoticeSeverityError,
|
||||||
@ -42,7 +41,9 @@ func newDiskStorage(scfg RootStorageConfig) *rootStorageDisk {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &rootStorageDisk{}
|
s := &rootStorageDisk{
|
||||||
|
settings: cfg,
|
||||||
|
}
|
||||||
|
|
||||||
if meta.Notice == nil {
|
if meta.Notice == nil {
|
||||||
path := fmt.Sprintf("file://%s", cfg.Path)
|
path := fmt.Sprintf("file://%s", cfg.Path)
|
||||||
@ -63,10 +64,17 @@ func newDiskStorage(scfg RootStorageConfig) *rootStorageDisk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.meta = meta
|
s.meta = meta
|
||||||
s.settings = cfg
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *rootStorageDisk) Meta() RootStorageMeta {
|
||||||
|
return s.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rootStorageDisk) Store() filestorage.FileStorage {
|
||||||
|
return s.store
|
||||||
|
}
|
||||||
|
|
||||||
func (s *rootStorageDisk) Sync() error {
|
func (s *rootStorageDisk) Sync() error {
|
||||||
return nil // already in sync
|
return nil // already in sync
|
||||||
}
|
}
|
||||||
|
387
pkg/services/store/storage_git.go
Normal file
387
pkg/services/store/storage_git.go
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/filestorage"
|
||||||
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
)
|
||||||
|
|
||||||
|
const rootStorageTypeGit = "git"
|
||||||
|
|
||||||
|
var _ storageRuntime = &rootStorageGit{}
|
||||||
|
|
||||||
|
type rootStorageGit struct {
|
||||||
|
settings *StorageGitConfig
|
||||||
|
repo *git.Repository
|
||||||
|
root string // repostitory root
|
||||||
|
|
||||||
|
github *githubHelper
|
||||||
|
meta RootStorageMeta
|
||||||
|
store filestorage.FileStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGitStorage(meta RootStorageMeta, scfg RootStorageConfig, localWorkCache string) *rootStorageGit {
|
||||||
|
cfg := scfg.Git
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &StorageGitConfig{}
|
||||||
|
}
|
||||||
|
scfg.Type = rootStorageTypeGit
|
||||||
|
scfg.GCS = nil
|
||||||
|
scfg.SQL = nil
|
||||||
|
scfg.S3 = nil
|
||||||
|
scfg.Git = cfg
|
||||||
|
|
||||||
|
meta.Config = scfg
|
||||||
|
if scfg.Prefix == "" {
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityError,
|
||||||
|
Text: "Missing prefix",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if cfg.Remote == "" {
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityError,
|
||||||
|
Text: "Missing remote path configuration",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(localWorkCache) < 2 {
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityError,
|
||||||
|
Text: "Invalid local root folder",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &rootStorageGit{
|
||||||
|
settings: cfg,
|
||||||
|
}
|
||||||
|
if meta.Notice == nil {
|
||||||
|
err := os.MkdirAll(localWorkCache, 0750)
|
||||||
|
if err != nil {
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityError,
|
||||||
|
Text: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if scfg.Disabled {
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityWarning,
|
||||||
|
Text: "folder is disabled (in configuration)",
|
||||||
|
})
|
||||||
|
} else if setting.Env == setting.Prod {
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityError,
|
||||||
|
Text: "git is only supported in dev mode (for now)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.Notice == nil {
|
||||||
|
repo, err := git.PlainOpen(localWorkCache)
|
||||||
|
if errors.Is(err, git.ErrRepositoryNotExists) {
|
||||||
|
repo, err = git.PlainClone(localWorkCache, false, &git.CloneOptions{
|
||||||
|
URL: cfg.Remote,
|
||||||
|
Progress: os.Stdout,
|
||||||
|
//Depth: 1,
|
||||||
|
//SingleBranch: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityError,
|
||||||
|
Text: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
p := localWorkCache
|
||||||
|
if cfg.Root != "" {
|
||||||
|
p = filepath.Join(p, cfg.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := fmt.Sprintf("file://%s", p)
|
||||||
|
bucket, err := blob.OpenBucket(context.Background(), path)
|
||||||
|
if err != nil {
|
||||||
|
grafanaStorageLogger.Warn("error loading storage", "prefix", scfg.Prefix, "err", err)
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityError,
|
||||||
|
Text: "Failed to initialize storage",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
s.store = filestorage.NewCdkBlobStorage(
|
||||||
|
grafanaStorageLogger,
|
||||||
|
bucket, "", nil)
|
||||||
|
|
||||||
|
meta.Ready = true // exists!
|
||||||
|
s.root = p
|
||||||
|
|
||||||
|
token := cfg.AccessToken
|
||||||
|
if strings.HasPrefix(token, "$") {
|
||||||
|
token = os.Getenv(token[1:])
|
||||||
|
if token == "" {
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityError,
|
||||||
|
Text: "Unable to find token environment variable: " + cfg.AccessToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
s.github, err = newGithubHelper(context.Background(), cfg.Remote, token)
|
||||||
|
if err != nil {
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityError,
|
||||||
|
Text: "error creating github client: " + err.Error(),
|
||||||
|
})
|
||||||
|
s.github = nil
|
||||||
|
} else {
|
||||||
|
ghrepo, _, err := s.github.getRepo(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityError,
|
||||||
|
Text: err.Error(),
|
||||||
|
})
|
||||||
|
s.github = nil
|
||||||
|
} else {
|
||||||
|
grafanaStorageLogger.Info("default branch", "branch", *ghrepo.DefaultBranch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.repo = repo
|
||||||
|
|
||||||
|
// Try pulling after init
|
||||||
|
if s.repo != nil && !scfg.Disabled {
|
||||||
|
err = s.Sync()
|
||||||
|
if err != nil {
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityError,
|
||||||
|
Text: "unable to pull: " + err.Error(),
|
||||||
|
})
|
||||||
|
} else if cfg.PullInterval != "" {
|
||||||
|
t, err := time.ParseDuration(cfg.PullInterval)
|
||||||
|
if err != nil {
|
||||||
|
meta.Notice = append(meta.Notice, data.Notice{
|
||||||
|
Severity: data.NoticeSeverityError,
|
||||||
|
Text: "Invalid pull interval " + cfg.PullInterval,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ticker := time.NewTicker(t)
|
||||||
|
go func() {
|
||||||
|
for range ticker.C {
|
||||||
|
grafanaStorageLogger.Info("try git pull", "branch", s.settings.Remote)
|
||||||
|
err = s.Sync()
|
||||||
|
if err != nil {
|
||||||
|
grafanaStorageLogger.Info("error pulling", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.meta = meta
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rootStorageGit) Meta() RootStorageMeta {
|
||||||
|
return s.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rootStorageGit) Store() filestorage.FileStorage {
|
||||||
|
return s.store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rootStorageGit) Pull() error {
|
||||||
|
w, err := s.repo.Worktree()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = w.Pull(&git.PullOptions{
|
||||||
|
// Depth: 1,
|
||||||
|
//SingleBranch: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rootStorageGit) Write(ctx context.Context, cmd *WriteValueRequest) (*WriteValueResponse, error) {
|
||||||
|
if s.github == nil {
|
||||||
|
return nil, fmt.Errorf("github client not initialized")
|
||||||
|
}
|
||||||
|
// Write to the correct subfolder
|
||||||
|
if s.settings.Root != "" {
|
||||||
|
cmd.Path = s.settings.Root + cmd.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Workflow == WriteValueWorkflow_PR {
|
||||||
|
prcmd := makePRCommand{
|
||||||
|
baseBranch: s.settings.Branch,
|
||||||
|
headBranch: fmt.Sprintf("grafana_ui_%d", time.Now().UnixMilli()),
|
||||||
|
title: cmd.Title,
|
||||||
|
body: cmd.Message,
|
||||||
|
}
|
||||||
|
res := &WriteValueResponse{
|
||||||
|
Branch: prcmd.headBranch,
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, _, err := s.github.createRef(ctx, prcmd.baseBranch, prcmd.headBranch)
|
||||||
|
if err != nil {
|
||||||
|
res.Code = 500
|
||||||
|
res.Message = "unable to create branch"
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.github.pushCommit(ctx, ref, cmd)
|
||||||
|
if err != nil {
|
||||||
|
res.Code = 500
|
||||||
|
res.Message = fmt.Sprintf("error creating commit: %s", err.Error())
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if prcmd.title == "" {
|
||||||
|
prcmd.title = "Dashboard save: " + time.Now().String()
|
||||||
|
}
|
||||||
|
if prcmd.body == "" {
|
||||||
|
prcmd.body = "Dashboard save: " + time.Now().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
pr, _, err := s.github.createPR(ctx, prcmd)
|
||||||
|
if err != nil {
|
||||||
|
res.Code = 500
|
||||||
|
res.Message = "error creating PR: " + err.Error()
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Code = 200
|
||||||
|
res.URL = pr.GetHTMLURL()
|
||||||
|
res.Pending = true
|
||||||
|
res.Hash = *ref.Object.SHA
|
||||||
|
res.Branch = prcmd.headBranch
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to remote branch (save)
|
||||||
|
if cmd.Workflow == WriteValueWorkflow_Push || true {
|
||||||
|
res := &WriteValueResponse{
|
||||||
|
Branch: s.settings.Branch,
|
||||||
|
}
|
||||||
|
ref, _, err := s.github.getRef(ctx, s.settings.Branch)
|
||||||
|
if err != nil {
|
||||||
|
res.Code = 500
|
||||||
|
res.Message = "unable to create branch"
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
err = s.github.pushCommit(ctx, ref, cmd)
|
||||||
|
if err != nil {
|
||||||
|
res.Code = 500
|
||||||
|
res.Message = "error creating commit"
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
ref, _, _ = s.github.getRef(ctx, s.settings.Branch)
|
||||||
|
if ref != nil {
|
||||||
|
res.Hash = *ref.Object.SHA
|
||||||
|
res.URL = ref.GetURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Pull()
|
||||||
|
if err != nil {
|
||||||
|
res.Message = "error pulling: " + err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Code = 200
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rel := cmd.Path
|
||||||
|
if s.meta.Config.Git.Root != "" {
|
||||||
|
rel = filepath.Join(s.meta.Config.Git.Root, cmd.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fpath := filepath.Join(s.root, rel)
|
||||||
|
err := os.WriteFile(fpath, cmd.Body, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := s.repo.Worktree()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file we just wrote
|
||||||
|
_, err = w.Add(rel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := cmd.Message
|
||||||
|
if msg == "" {
|
||||||
|
msg = "changes from grafana ui"
|
||||||
|
}
|
||||||
|
user := cmd.User
|
||||||
|
if user == nil {
|
||||||
|
user = &models.SignedInUser{}
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := w.Commit(msg, &git.CommitOptions{
|
||||||
|
Author: &object.Signature{
|
||||||
|
Name: firstRealString(user.Name, user.Login, user.Email, "?"),
|
||||||
|
Email: firstRealString(user.Email, user.Login, user.Name, "?"),
|
||||||
|
When: time.Now(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
grafanaStorageLogger.Info("made commit", "hash", hash)
|
||||||
|
// err = s.repo.Push(&git.PushOptions{
|
||||||
|
// InsecureSkipTLS: true,
|
||||||
|
// })
|
||||||
|
|
||||||
|
return &WriteValueResponse{
|
||||||
|
Hash: hash.String(),
|
||||||
|
Message: "made commit",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rootStorageGit) Sync() error {
|
||||||
|
grafanaStorageLogger.Info("GIT PULL", "remote", s.settings.Remote)
|
||||||
|
err := s.Pull()
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "already up-to-date" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstRealString(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "?"
|
||||||
|
}
|
@ -13,10 +13,12 @@ import (
|
|||||||
|
|
||||||
const rootStorageTypeSQL = "sql"
|
const rootStorageTypeSQL = "sql"
|
||||||
|
|
||||||
type rootStorageSQL struct {
|
var _ storageRuntime = &rootStorageSQL{}
|
||||||
baseStorageRuntime
|
|
||||||
|
|
||||||
|
type rootStorageSQL struct {
|
||||||
settings *StorageSQLConfig
|
settings *StorageSQLConfig
|
||||||
|
meta RootStorageMeta
|
||||||
|
store filestorage.FileStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDbRootFolder creates a DB path prefix for a given storage name and orgId.
|
// getDbRootFolder creates a DB path prefix for a given storage name and orgId.
|
||||||
@ -28,19 +30,17 @@ func getDbStoragePathPrefix(orgId int64, storageName string) string {
|
|||||||
return filestorage.Join(fmt.Sprintf("%d", orgId), storageName+filestorage.Delimiter)
|
return filestorage.Join(fmt.Sprintf("%d", orgId), storageName+filestorage.Delimiter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSQLStorage(prefix string, name string, descr string, cfg *StorageSQLConfig, sql *sqlstore.SQLStore, orgId int64) *rootStorageSQL {
|
func newSQLStorage(meta RootStorageMeta, prefix string, name string, descr string, cfg *StorageSQLConfig, sql *sqlstore.SQLStore, orgId int64) *rootStorageSQL {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
cfg = &StorageSQLConfig{}
|
cfg = &StorageSQLConfig{}
|
||||||
}
|
}
|
||||||
|
|
||||||
meta := RootStorageMeta{
|
meta.Config = RootStorageConfig{
|
||||||
Config: RootStorageConfig{
|
Type: rootStorageTypeSQL,
|
||||||
Type: rootStorageTypeSQL,
|
Prefix: prefix,
|
||||||
Prefix: prefix,
|
Name: name,
|
||||||
Name: name,
|
Description: descr,
|
||||||
Description: descr,
|
SQL: cfg,
|
||||||
SQL: cfg,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
@ -78,6 +78,14 @@ func (s *rootStorageSQL) Write(ctx context.Context, cmd *WriteValueRequest) (*Wr
|
|||||||
return &WriteValueResponse{Code: 200}, nil
|
return &WriteValueResponse{Code: 200}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *rootStorageSQL) Meta() RootStorageMeta {
|
||||||
|
return s.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rootStorageSQL) Store() filestorage.FileStorage {
|
||||||
|
return s.store
|
||||||
|
}
|
||||||
|
|
||||||
func (s *rootStorageSQL) Sync() error {
|
func (s *rootStorageSQL) Sync() error {
|
||||||
return nil // already in sync
|
return nil // already in sync
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||||
@ -77,12 +78,15 @@ func (t *nestedTree) GetFile(ctx context.Context, orgId int64, path string) (*fi
|
|||||||
if path == "" {
|
if path == "" {
|
||||||
return nil, nil // not found
|
return nil, nil // not found
|
||||||
}
|
}
|
||||||
|
|
||||||
root, path := t.getRoot(orgId, path)
|
root, path := t.getRoot(orgId, path)
|
||||||
if root == nil {
|
if root == nil {
|
||||||
return nil, nil // not found (or not ready)
|
return nil, nil // not found (or not ready)
|
||||||
}
|
}
|
||||||
return root.Store().Get(ctx, path)
|
store := root.Store()
|
||||||
|
if store == nil {
|
||||||
|
return nil, fmt.Errorf("store not ready")
|
||||||
|
}
|
||||||
|
return store.Get(ctx, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string, accessFilter filestorage.PathFilter) (*StorageListFrame, error) {
|
func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string, accessFilter filestorage.PathFilter) (*StorageListFrame, error) {
|
||||||
@ -98,26 +102,17 @@ func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string, a
|
|||||||
names := data.NewFieldFromFieldType(data.FieldTypeString, count)
|
names := data.NewFieldFromFieldType(data.FieldTypeString, count)
|
||||||
title := data.NewFieldFromFieldType(data.FieldTypeString, count)
|
title := data.NewFieldFromFieldType(data.FieldTypeString, count)
|
||||||
descr := data.NewFieldFromFieldType(data.FieldTypeString, count)
|
descr := data.NewFieldFromFieldType(data.FieldTypeString, count)
|
||||||
types := data.NewFieldFromFieldType(data.FieldTypeString, count)
|
|
||||||
readOnly := data.NewFieldFromFieldType(data.FieldTypeBool, count)
|
|
||||||
builtIn := data.NewFieldFromFieldType(data.FieldTypeBool, count)
|
|
||||||
mtype := data.NewFieldFromFieldType(data.FieldTypeString, count)
|
mtype := data.NewFieldFromFieldType(data.FieldTypeString, count)
|
||||||
title.Name = titleListFrameField
|
title.Name = titleListFrameField
|
||||||
names.Name = nameListFrameField
|
names.Name = nameListFrameField
|
||||||
descr.Name = descriptionListFrameField
|
descr.Name = descriptionListFrameField
|
||||||
mtype.Name = mediaTypeListFrameField
|
mtype.Name = mediaTypeListFrameField
|
||||||
types.Name = storageTypeListFrameField
|
|
||||||
readOnly.Name = readOnlyListFrameField
|
|
||||||
builtIn.Name = builtInListFrameField
|
|
||||||
for _, f := range t.rootsByOrgId[ac.GlobalOrgID] {
|
for _, f := range t.rootsByOrgId[ac.GlobalOrgID] {
|
||||||
meta := f.Meta()
|
meta := f.Meta()
|
||||||
names.Set(idx, meta.Config.Prefix)
|
names.Set(idx, meta.Config.Prefix)
|
||||||
title.Set(idx, meta.Config.Name)
|
title.Set(idx, meta.Config.Name)
|
||||||
descr.Set(idx, meta.Config.Description)
|
descr.Set(idx, meta.Config.Description)
|
||||||
mtype.Set(idx, "directory")
|
mtype.Set(idx, "directory")
|
||||||
types.Set(idx, meta.Config.Type)
|
|
||||||
readOnly.Set(idx, meta.ReadOnly)
|
|
||||||
builtIn.Set(idx, meta.Builtin)
|
|
||||||
idx++
|
idx++
|
||||||
}
|
}
|
||||||
if orgId != ac.GlobalOrgID {
|
if orgId != ac.GlobalOrgID {
|
||||||
@ -127,14 +122,11 @@ func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string, a
|
|||||||
title.Set(idx, meta.Config.Name)
|
title.Set(idx, meta.Config.Name)
|
||||||
descr.Set(idx, meta.Config.Description)
|
descr.Set(idx, meta.Config.Description)
|
||||||
mtype.Set(idx, "directory")
|
mtype.Set(idx, "directory")
|
||||||
types.Set(idx, meta.Config.Type)
|
|
||||||
readOnly.Set(idx, meta.ReadOnly)
|
|
||||||
builtIn.Set(idx, meta.Builtin)
|
|
||||||
idx++
|
idx++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
frame := data.NewFrame("", names, title, descr, mtype, types, readOnly, builtIn)
|
frame := data.NewFrame("", names, title, descr, mtype)
|
||||||
frame.SetMeta(&data.FrameMeta{
|
frame.SetMeta(&data.FrameMeta{
|
||||||
Type: data.FrameTypeDirectoryListing,
|
Type: data.FrameTypeDirectoryListing,
|
||||||
})
|
})
|
||||||
@ -146,7 +138,12 @@ func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string, a
|
|||||||
return nil, nil // not found (or not ready)
|
return nil, nil // not found (or not ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
listResponse, err := root.Store().List(ctx, path, nil, &filestorage.ListOptions{
|
store := root.Store()
|
||||||
|
if store == nil {
|
||||||
|
return nil, fmt.Errorf("store not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
listResponse, err := store.List(ctx, path, nil, &filestorage.ListOptions{
|
||||||
Recursive: false,
|
Recursive: false,
|
||||||
WithFolders: true,
|
WithFolders: true,
|
||||||
WithFiles: true,
|
WithFiles: true,
|
||||||
|
@ -9,13 +9,22 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type WriteValueWorkflow = string
|
||||||
|
|
||||||
|
var (
|
||||||
|
WriteValueWorkflow_Save WriteValueWorkflow = "save" // or empty
|
||||||
|
WriteValueWorkflow_PR WriteValueWorkflow = "pr"
|
||||||
|
WriteValueWorkflow_Push WriteValueWorkflow = "push"
|
||||||
|
)
|
||||||
|
|
||||||
type WriteValueRequest struct {
|
type WriteValueRequest struct {
|
||||||
Path string
|
User *models.SignedInUser
|
||||||
User *models.SignedInUser
|
Path string // added from URL
|
||||||
Body json.RawMessage `json:"body,omitempty"`
|
EntityType EntityType `json:"kind,omitempty"` // for now only dashboard
|
||||||
Message string `json:"message,omitempty"`
|
Body json.RawMessage `json:"body,omitempty"`
|
||||||
Title string `json:"title,omitempty"` // For PRs
|
Message string `json:"message,omitempty"`
|
||||||
Action string `json:"action,omitempty"` // pr | save
|
Title string `json:"title,omitempty"` // For PRs
|
||||||
|
Workflow WriteValueWorkflow `json:"workflow,omitempty"` // save | pr | push
|
||||||
}
|
}
|
||||||
|
|
||||||
type WriteValueResponse struct {
|
type WriteValueResponse struct {
|
||||||
@ -48,40 +57,6 @@ type storageRuntime interface {
|
|||||||
Write(ctx context.Context, cmd *WriteValueRequest) (*WriteValueResponse, error)
|
Write(ctx context.Context, cmd *WriteValueRequest) (*WriteValueResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type baseStorageRuntime struct {
|
|
||||||
meta RootStorageMeta
|
|
||||||
store filestorage.FileStorage
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *baseStorageRuntime) Meta() RootStorageMeta {
|
|
||||||
return t.meta
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *baseStorageRuntime) Store() filestorage.FileStorage {
|
|
||||||
return t.store
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *baseStorageRuntime) Sync() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *baseStorageRuntime) Write(ctx context.Context, cmd *WriteValueRequest) (*WriteValueResponse, error) {
|
|
||||||
return &WriteValueResponse{
|
|
||||||
Code: 500,
|
|
||||||
Message: "unsupportted operation (base)",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *baseStorageRuntime) setReadOnly(val bool) *baseStorageRuntime {
|
|
||||||
t.meta.ReadOnly = val
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *baseStorageRuntime) setBuiltin(val bool) *baseStorageRuntime {
|
|
||||||
t.meta.Builtin = val
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
type RootStorageMeta struct {
|
type RootStorageMeta struct {
|
||||||
ReadOnly bool `json:"editable,omitempty"`
|
ReadOnly bool `json:"editable,omitempty"`
|
||||||
Builtin bool `json:"builtin,omitempty"`
|
Builtin bool `json:"builtin,omitempty"`
|
||||||
@ -100,9 +75,6 @@ const (
|
|||||||
nameListFrameField = "name"
|
nameListFrameField = "name"
|
||||||
descriptionListFrameField = "description"
|
descriptionListFrameField = "description"
|
||||||
mediaTypeListFrameField = "mediaType"
|
mediaTypeListFrameField = "mediaType"
|
||||||
storageTypeListFrameField = "storageType"
|
|
||||||
readOnlyListFrameField = "readOnly"
|
|
||||||
builtInListFrameField = "builtIn"
|
|
||||||
sizeListFrameField = "size"
|
sizeListFrameField = "size"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useAsync } from 'react-use';
|
import { useAsync } from 'react-use';
|
||||||
|
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
import { Drawer, Spinner, Tab, TabsBar } from '@grafana/ui';
|
import { Drawer, Spinner, Tab, TabsBar } from '@grafana/ui';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
|
|
||||||
@ -11,14 +12,16 @@ import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
|
|||||||
import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm';
|
import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm';
|
||||||
import { SaveDashboardForm } from './forms/SaveDashboardForm';
|
import { SaveDashboardForm } from './forms/SaveDashboardForm';
|
||||||
import { SaveProvisionedDashboardForm } from './forms/SaveProvisionedDashboardForm';
|
import { SaveProvisionedDashboardForm } from './forms/SaveProvisionedDashboardForm';
|
||||||
|
import { SaveToStorageForm } from './forms/SaveToStorageForm';
|
||||||
import { SaveDashboardData, SaveDashboardModalProps, SaveDashboardOptions } from './types';
|
import { SaveDashboardData, SaveDashboardModalProps, SaveDashboardOptions } from './types';
|
||||||
import { useDashboardSave } from './useDashboardSave';
|
import { useDashboardSave } from './useDashboardSave';
|
||||||
|
|
||||||
export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCopy }: SaveDashboardModalProps) => {
|
export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCopy }: SaveDashboardModalProps) => {
|
||||||
const [options, setOptions] = useState<SaveDashboardOptions>({});
|
const [options, setOptions] = useState<SaveDashboardOptions>({});
|
||||||
|
|
||||||
const isProvisioned = dashboard.meta.provisioned;
|
const isFromStorage = config.featureToggles.dashboardsFromStorage && dashboard.uid.indexOf('/') > 0;
|
||||||
const isNew = dashboard.version === 0;
|
const isProvisioned = dashboard.meta.provisioned && !isFromStorage;
|
||||||
|
const isNew = dashboard.version === 0 && !isFromStorage;
|
||||||
|
|
||||||
const previous = useAsync(async () => {
|
const previous = useAsync(async () => {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
@ -78,6 +81,22 @@ export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCop
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFromStorage) {
|
||||||
|
return (
|
||||||
|
<SaveToStorageForm
|
||||||
|
dashboard={dashboard}
|
||||||
|
saveModel={data}
|
||||||
|
onCancel={onDismiss}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
onSubmit={onDashboardSave}
|
||||||
|
options={options}
|
||||||
|
onOptionsChange={setOptions}
|
||||||
|
isNew={isNew}
|
||||||
|
isCopy={isCopy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isNew || isCopy) {
|
if (isNew || isCopy) {
|
||||||
return (
|
return (
|
||||||
<SaveDashboardAsForm
|
<SaveDashboardAsForm
|
||||||
|
@ -10,7 +10,7 @@ interface FormDTO {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
export type SaveProps = {
|
||||||
dashboard: DashboardModel; // original
|
dashboard: DashboardModel; // original
|
||||||
saveModel: SaveDashboardData; // already cloned
|
saveModel: SaveDashboardData; // already cloned
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@ -28,7 +28,7 @@ export const SaveDashboardForm = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onOptionsChange,
|
onOptionsChange,
|
||||||
}: Props) => {
|
}: SaveProps) => {
|
||||||
const hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [dashboard]);
|
const hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [dashboard]);
|
||||||
const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]);
|
const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]);
|
||||||
|
|
||||||
|
@ -0,0 +1,222 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useAsync } from 'react-use';
|
||||||
|
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
import { locationService } from '@grafana/runtime';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Field,
|
||||||
|
Form,
|
||||||
|
HorizontalGroup,
|
||||||
|
Input,
|
||||||
|
RadioButtonGroup,
|
||||||
|
Spinner,
|
||||||
|
Stack,
|
||||||
|
TextArea,
|
||||||
|
} from '@grafana/ui';
|
||||||
|
import { getGrafanaStorage } from 'app/features/storage/storage';
|
||||||
|
import { ItemOptions, WorkflowID, WriteValueResponse } from 'app/features/storage/types';
|
||||||
|
|
||||||
|
import { SaveProps } from './SaveDashboardForm';
|
||||||
|
|
||||||
|
interface FormDTO {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props extends SaveProps {
|
||||||
|
isNew?: boolean;
|
||||||
|
isCopy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveToStorageForm(props: Props) {
|
||||||
|
const { dashboard, saveModel, onSubmit, onCancel, onSuccess, onOptionsChange, isNew, isCopy } = props;
|
||||||
|
const hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [dashboard]);
|
||||||
|
const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [response, setResponse] = useState<WriteValueResponse>();
|
||||||
|
const [path, setPath] = useState(dashboard.uid);
|
||||||
|
const [workflow, setWorkflow] = useState(WorkflowID.Save);
|
||||||
|
const saveText = useMemo(() => {
|
||||||
|
switch (workflow) {
|
||||||
|
case WorkflowID.PR:
|
||||||
|
return 'Create PR';
|
||||||
|
case WorkflowID.Push:
|
||||||
|
return 'Push';
|
||||||
|
}
|
||||||
|
console.log('???', workflow);
|
||||||
|
return 'Save';
|
||||||
|
}, [workflow]);
|
||||||
|
|
||||||
|
const item = useAsync(async () => {
|
||||||
|
const opts = await getGrafanaStorage().getOptions(dashboard.uid);
|
||||||
|
setWorkflow(opts.workflows[0]?.value ?? WorkflowID.Save);
|
||||||
|
return opts;
|
||||||
|
}, [dashboard.uid]);
|
||||||
|
|
||||||
|
if (item.error) {
|
||||||
|
return <div>Error loading workflows</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.loading || !item.value) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{response.url && (
|
||||||
|
<div>
|
||||||
|
<h2>View pull request</h2>
|
||||||
|
<a href={response.url}>{response.url}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<pre>{JSON.stringify(response)}</pre>
|
||||||
|
|
||||||
|
<HorizontalGroup>
|
||||||
|
<Button variant="secondary" onClick={onCancel}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = props.options;
|
||||||
|
const workflows = item.value?.workflows ?? [];
|
||||||
|
const canSave = saveModel.hasChanges || isNew || isCopy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
onSubmit={async (data: FormDTO) => {
|
||||||
|
if (!onSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
let uid = saveModel.clone.uid;
|
||||||
|
if (isNew || isCopy) {
|
||||||
|
uid = path;
|
||||||
|
if (!uid.endsWith('-dash.json')) {
|
||||||
|
uid += '-dash.json';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rsp = await getGrafanaStorage().write(uid, {
|
||||||
|
body: saveModel.clone,
|
||||||
|
kind: 'dashboard',
|
||||||
|
title: data.title,
|
||||||
|
message: data.message,
|
||||||
|
workflow: workflow,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('GOT', rsp);
|
||||||
|
if (rsp.code === 200) {
|
||||||
|
if (options.saveVariables) {
|
||||||
|
dashboard.resetOriginalVariables();
|
||||||
|
}
|
||||||
|
if (options.saveTimerange) {
|
||||||
|
dashboard.resetOriginalTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rsp.pending) {
|
||||||
|
// should close
|
||||||
|
onSuccess();
|
||||||
|
|
||||||
|
// Need to update the URL
|
||||||
|
if (isNew || isCopy) {
|
||||||
|
locationService.push(`/g/${uid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
setResponse(rsp);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ register, errors }) => (
|
||||||
|
<Stack direction="column" gap={1}>
|
||||||
|
<Stack direction="column" gap={1}>
|
||||||
|
{hasTimeChanged && (
|
||||||
|
<Checkbox
|
||||||
|
checked={!!options.saveTimerange}
|
||||||
|
onChange={() =>
|
||||||
|
onOptionsChange({
|
||||||
|
...options,
|
||||||
|
saveTimerange: !options.saveTimerange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Save current time range as dashboard default"
|
||||||
|
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasVariableChanged && (
|
||||||
|
<Checkbox
|
||||||
|
checked={!!options.saveVariables}
|
||||||
|
onChange={() =>
|
||||||
|
onOptionsChange({
|
||||||
|
...options,
|
||||||
|
saveVariables: !options.saveVariables,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Save current variable values as dashboard default"
|
||||||
|
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{(isNew || isCopy) && (
|
||||||
|
<Field label="Path">
|
||||||
|
<Input
|
||||||
|
value={path ?? ''}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
placeholder="Full path (todo, help validate)"
|
||||||
|
onChange={(v) => setPath(v.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isJustSave(item.value) && (
|
||||||
|
<Field label="Workflow">
|
||||||
|
<RadioButtonGroup value={workflow} options={workflows} onChange={setWorkflow} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{workflow === WorkflowID.PR && (
|
||||||
|
<Field label="PR Title">
|
||||||
|
<Input {...register('title')} required placeholder="Enter a PR title" autoFocus />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Message">
|
||||||
|
<TextArea {...register('message')} placeholder="Add a note to describe your changes." rows={5} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<HorizontalGroup>
|
||||||
|
<Button variant="secondary" onClick={onCancel} fill="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSave}
|
||||||
|
icon={saving ? 'fa fa-spinner' : undefined}
|
||||||
|
aria-label={selectors.pages.SaveDashboardModal.save}
|
||||||
|
>
|
||||||
|
{saveText}
|
||||||
|
</Button>
|
||||||
|
{!canSave && <div>No changes to save</div>}
|
||||||
|
</HorizontalGroup>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJustSave(opts: ItemOptions): boolean {
|
||||||
|
if (opts.workflows.length === 1) {
|
||||||
|
return opts.workflows.find((v) => v.value === WorkflowID.Save) != null;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
@ -4,7 +4,6 @@ import { notifyApp } from 'app/core/actions';
|
|||||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||||
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
|
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||||
import { getGrafanaStorage } from 'app/features/storage/storage';
|
|
||||||
import { DashboardDTO, FolderInfo, PermissionLevelString, ThunkResult } from 'app/types';
|
import { DashboardDTO, FolderInfo, PermissionLevelString, ThunkResult } from 'app/types';
|
||||||
|
|
||||||
import { LibraryElementExport } from '../../dashboard/components/DashExportModal/DashboardExporter';
|
import { LibraryElementExport } from '../../dashboard/components/DashExportModal/DashboardExporter';
|
||||||
@ -267,10 +266,6 @@ export function deleteFoldersAndDashboards(folderUids: string[], dashboardUids:
|
|||||||
export function saveDashboard(options: SaveDashboardCommand) {
|
export function saveDashboard(options: SaveDashboardCommand) {
|
||||||
dashboardWatcher.ignoreNextSave();
|
dashboardWatcher.ignoreNextSave();
|
||||||
|
|
||||||
if (options.dashboard.uid.indexOf('/') > 0) {
|
|
||||||
return getGrafanaStorage().saveDashboard(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getBackendSrv().post('/api/dashboards/db/', {
|
return getBackendSrv().post('/api/dashboards/db/', {
|
||||||
dashboard: options.dashboard,
|
dashboard: options.dashboard,
|
||||||
message: options.message ?? '',
|
message: options.message ?? '',
|
||||||
|
@ -1,28 +1,33 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useAsync } from 'react-use';
|
||||||
|
|
||||||
import { DataFrame, DataFrameView, GrafanaTheme2 } from '@grafana/data';
|
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { Button, Card, FilterInput, Icon, IconName, TagList, useStyles2, VerticalGroup } from '@grafana/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
FilterInput,
|
||||||
|
HorizontalGroup,
|
||||||
|
Icon,
|
||||||
|
IconName,
|
||||||
|
TagList,
|
||||||
|
useStyles2,
|
||||||
|
VerticalGroup,
|
||||||
|
} from '@grafana/ui';
|
||||||
|
|
||||||
import { StorageView } from './types';
|
import { getGrafanaStorage } from './storage';
|
||||||
|
import { StorageInfo, StorageView } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
root: DataFrame;
|
root: DataFrame;
|
||||||
onPathChange: (p: string, v?: StorageView) => void;
|
onPathChange: (p: string, v?: StorageView) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RootFolder {
|
|
||||||
name: string;
|
|
||||||
title: string;
|
|
||||||
storageType: string;
|
|
||||||
description: string;
|
|
||||||
readOnly: boolean;
|
|
||||||
builtIn: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RootView({ root, onPathChange }: Props) {
|
export function RootView({ root, onPathChange }: Props) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const storage = useAsync(getGrafanaStorage().getConfig);
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
let base = location.pathname;
|
let base = location.pathname;
|
||||||
if (!base.endsWith('/')) {
|
if (!base.endsWith('/')) {
|
||||||
@ -30,11 +35,11 @@ export function RootView({ root, onPathChange }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roots = useMemo(() => {
|
const roots = useMemo(() => {
|
||||||
const view = new DataFrameView<RootFolder>(root);
|
const all = storage.value;
|
||||||
const all = view.map((v) => ({ ...v }));
|
if (searchQuery?.length && all) {
|
||||||
if (searchQuery?.length) {
|
|
||||||
const lower = searchQuery.toLowerCase();
|
const lower = searchQuery.toLowerCase();
|
||||||
return all.filter((v) => {
|
return all.filter((r) => {
|
||||||
|
const v = r.config;
|
||||||
const isMatch = v.name.toLowerCase().indexOf(lower) >= 0 || v.description.toLowerCase().indexOf(lower) >= 0;
|
const isMatch = v.name.toLowerCase().indexOf(lower) >= 0 || v.description.toLowerCase().indexOf(lower) >= 0;
|
||||||
if (isMatch) {
|
if (isMatch) {
|
||||||
return true;
|
return true;
|
||||||
@ -42,8 +47,8 @@ export function RootView({ root, onPathChange }: Props) {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return all;
|
return all ?? [];
|
||||||
}, [searchQuery, root]);
|
}, [searchQuery, storage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -61,18 +66,30 @@ export function RootView({ root, onPathChange }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<VerticalGroup>
|
<VerticalGroup>
|
||||||
{roots.map((v) => (
|
{roots.map((s) => {
|
||||||
<Card key={v.name} href={`admin/storage/${v.name}/`}>
|
const ok = s.ready;
|
||||||
<Card.Heading>{v.title ?? v.name}</Card.Heading>
|
return (
|
||||||
<Card.Meta className={styles.clickable}>{v.description}</Card.Meta>
|
<Card key={s.config.prefix} href={ok ? `admin/storage/${s.config.prefix}/` : undefined}>
|
||||||
<Card.Tags className={styles.clickable}>
|
<Card.Heading>{s.config.name}</Card.Heading>
|
||||||
<TagList tags={getTags(v)} />
|
<Card.Meta className={styles.clickable}>
|
||||||
</Card.Tags>
|
{s.config.description}
|
||||||
<Card.Figure className={styles.clickable}>
|
{s.config.git?.remote && <a href={s.config.git?.remote}>{s.config.git?.remote}</a>}
|
||||||
<Icon name={getIconName(v.storageType)} size="xxxl" className={styles.secondaryTextColor} />
|
</Card.Meta>
|
||||||
</Card.Figure>
|
{s.notice?.map((notice) => (
|
||||||
</Card>
|
<Alert key={notice.text} severity={notice.severity} title={notice.text} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<Card.Tags className={styles.clickable}>
|
||||||
|
<HorizontalGroup>
|
||||||
|
<TagList tags={getTags(s)} />
|
||||||
|
</HorizontalGroup>
|
||||||
|
</Card.Tags>
|
||||||
|
<Card.Figure className={styles.clickable}>
|
||||||
|
<Icon name={getIconName(s.config.type)} size="xxxl" className={styles.secondaryTextColor} />
|
||||||
|
</Card.Figure>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</VerticalGroup>
|
</VerticalGroup>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -89,14 +106,19 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTags(v: RootFolder) {
|
function getTags(v: StorageInfo) {
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
if (v.builtIn) {
|
if (v.builtin) {
|
||||||
tags.push('Builtin');
|
tags.push('Builtin');
|
||||||
}
|
}
|
||||||
if (v.readOnly) {
|
if (!v.editable) {
|
||||||
tags.push('Read only');
|
tags.push('Read only');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
if (!v.ready) {
|
||||||
|
tags.push('Not ready');
|
||||||
|
}
|
||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -157,8 +157,7 @@ export default function StoragePage(props: Props) {
|
|||||||
|
|
||||||
const canAddFolder = isFolder && path.startsWith('resources');
|
const canAddFolder = isFolder && path.startsWith('resources');
|
||||||
const canDelete = path.startsWith('resources/');
|
const canDelete = path.startsWith('resources/');
|
||||||
const canViewDashboard =
|
const canViewDashboard = config.featureToggles.dashboardsFromStorage && (isFolder || path.endsWith('.json'));
|
||||||
path.startsWith('devenv/') && config.featureToggles.dashboardsFromStorage && (isFolder || path.endsWith('.json'));
|
|
||||||
|
|
||||||
const getErrorMessages = () => {
|
const getErrorMessages = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { DataFrame, dataFrameFromJSON, DataFrameJSON, getDisplayProcessor } from '@grafana/data';
|
import { DataFrame, dataFrameFromJSON, DataFrameJSON, getDisplayProcessor } from '@grafana/data';
|
||||||
import { config, getBackendSrv } from '@grafana/runtime';
|
import { config, getBackendSrv } from '@grafana/runtime';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
|
|
||||||
import { DashboardDTO } from 'app/types';
|
import { DashboardDTO } from 'app/types';
|
||||||
|
|
||||||
import { UploadReponse } from './types';
|
import { UploadReponse, StorageInfo, ItemOptions, WriteValueRequest, WriteValueResponse } from './types';
|
||||||
|
|
||||||
// Likely should be built into the search interface!
|
// Likely should be built into the search interface!
|
||||||
export interface GrafanaStorage {
|
export interface GrafanaStorage {
|
||||||
@ -14,12 +13,20 @@ export interface GrafanaStorage {
|
|||||||
createFolder: (path: string) => Promise<{ error?: string }>;
|
createFolder: (path: string) => Promise<{ error?: string }>;
|
||||||
delete: (path: { isFolder: boolean; path: string }) => Promise<{ error?: string }>;
|
delete: (path: { isFolder: boolean; path: string }) => Promise<{ error?: string }>;
|
||||||
|
|
||||||
|
/** Admin only */
|
||||||
|
getConfig: () => Promise<StorageInfo[]>;
|
||||||
|
|
||||||
|
/** Called before save */
|
||||||
|
getOptions: (path: string) => Promise<ItemOptions>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Temporary shim that will return a DashboardDTO shape for files in storage
|
* Temporary shim that will return a DashboardDTO shape for files in storage
|
||||||
* Longer term, this will call an "Entity API" that is eventually backed by storage
|
* Longer term, this will call an "Entity API" that is eventually backed by storage
|
||||||
*/
|
*/
|
||||||
getDashboard: (path: string) => Promise<DashboardDTO>;
|
getDashboard: (path: string) => Promise<DashboardDTO>;
|
||||||
saveDashboard: (options: SaveDashboardCommand) => Promise<any>;
|
|
||||||
|
/** Saves dashbaords */
|
||||||
|
write: (path: string, options: WriteValueRequest) => Promise<WriteValueResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SimpleStorage implements GrafanaStorage {
|
class SimpleStorage implements GrafanaStorage {
|
||||||
@ -140,40 +147,16 @@ class SimpleStorage implements GrafanaStorage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveDashboard(options: SaveDashboardCommand): Promise<any> {
|
async write(path: string, options: WriteValueRequest): Promise<WriteValueResponse> {
|
||||||
if (!config.featureToggles.dashboardsFromStorage) {
|
return backendSrv.post<WriteValueResponse>(`/api/storage/write/${path}`, options);
|
||||||
return Promise.reject('Dashboards from storage is not enabled');
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([JSON.stringify(options.dashboard)], {
|
async getConfig() {
|
||||||
type: 'application/json',
|
return getBackendSrv().get<StorageInfo[]>('/api/storage/config');
|
||||||
});
|
}
|
||||||
|
|
||||||
const uid = options.dashboard.uid;
|
async getOptions(path: string) {
|
||||||
const formData = new FormData();
|
return getBackendSrv().get<ItemOptions>(`/api/storage/options/${path}`);
|
||||||
if (options.message) {
|
|
||||||
formData.append('message', options.message);
|
|
||||||
}
|
|
||||||
formData.append('overwriteExistingFile', options.overwrite === false ? 'false' : 'true');
|
|
||||||
formData.append('file.path', uid);
|
|
||||||
formData.append('file', blob);
|
|
||||||
const res = await fetch('/api/storage/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
let body = (await res.json()) as UploadReponse;
|
|
||||||
if (res.status !== 200 && !body?.err) {
|
|
||||||
console.log('SAVE', options, body);
|
|
||||||
return Promise.reject({ message: body?.message ?? res.statusText });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
uid,
|
|
||||||
url: `/g/${uid}`,
|
|
||||||
slug: uid,
|
|
||||||
status: 'success',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { QueryResultMetaNotice, SelectableValue } from '@grafana/data';
|
||||||
|
|
||||||
export enum StorageView {
|
export enum StorageView {
|
||||||
Data = 'data',
|
Data = 'data',
|
||||||
Config = 'config',
|
Config = 'config',
|
||||||
@ -15,3 +17,58 @@ export interface UploadReponse {
|
|||||||
message: string;
|
message: string;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StorageInfo {
|
||||||
|
editable?: boolean;
|
||||||
|
builtin?: boolean;
|
||||||
|
ready?: boolean;
|
||||||
|
notice?: QueryResultMetaNotice[];
|
||||||
|
config: StorageConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageConfig {
|
||||||
|
type: string;
|
||||||
|
prefix: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
disk?: {
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
git?: {
|
||||||
|
remote: string;
|
||||||
|
branch: string;
|
||||||
|
root: string;
|
||||||
|
requirePullRequest: boolean;
|
||||||
|
accessToken: string;
|
||||||
|
};
|
||||||
|
sql?: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum WorkflowID {
|
||||||
|
Save = 'save',
|
||||||
|
PR = 'pr',
|
||||||
|
Push = 'push',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteValueRequest {
|
||||||
|
kind: string;
|
||||||
|
body: {}; // json body
|
||||||
|
message?: string;
|
||||||
|
title?: string;
|
||||||
|
workflow: WorkflowID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteValueResponse {
|
||||||
|
code: number;
|
||||||
|
message?: string;
|
||||||
|
url?: string;
|
||||||
|
hash?: string;
|
||||||
|
branch?: string;
|
||||||
|
pending?: boolean;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemOptions {
|
||||||
|
path: string;
|
||||||
|
workflows: Array<SelectableValue<WorkflowID>>;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user