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": [
|
||||
[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.", "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.", "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"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
|
||||
],
|
||||
"public/app/features/teams/CreateTeam.test.tsx:5381": [
|
||||
[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"]
|
||||
],
|
||||
"public/app/plugins/panel/barchart/BarChartPanel.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[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": [
|
||||
[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-stack/stack v1.8.0
|
||||
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/golang/mock v1.6.0
|
||||
github.com/golang/snappy v0.0.4
|
||||
@ -245,6 +245,7 @@ require (
|
||||
github.com/blugelabs/bluge_segment_api v0.2.0
|
||||
github.com/getkin/kin-openapi v0.94.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/thema v0.0.0-20220726124731-b8017e278cc1
|
||||
go.etcd.io/etcd/api/v3 v3.5.4
|
||||
@ -258,6 +259,7 @@ require (
|
||||
cloud.google.com/go v0.100.2 // indirect
|
||||
github.com/armon/go-metrics v0.3.10 // 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/gosimple/unidecode v1.0.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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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.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/httpreplay v1.1.1/go.mod h1:gN9GeLIs7l6NUoVaSSnv2RiqK1NiwAmD0MrKeC9IIks=
|
||||
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) {
|
||||
apiRoute.Group("/storage", func(storageRoute routing.RouteRegister) {
|
||||
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))
|
||||
})
|
||||
apiRoute.Group("/storage", hs.StorageService.RegisterHTTPRoutes)
|
||||
}
|
||||
|
||||
// current org
|
||||
|
@ -128,7 +128,7 @@ type HTTPServer struct {
|
||||
LivePushGateway *pushhttp.Gateway
|
||||
ThumbService thumbs.Service
|
||||
ExportService export.ExportService
|
||||
StorageService store.HTTPStorageService
|
||||
StorageService store.StorageService
|
||||
ContextHandler *contexthandler.ContextHandler
|
||||
SQLStore sqlstore.Store
|
||||
AlertEngine *alerting.AlertEngine
|
||||
@ -202,7 +202,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
||||
pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service,
|
||||
dataSourcesService datasources.DataSourceService, secretsService secrets.Service, queryDataService *query.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,
|
||||
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService,
|
||||
datasourcePermissionsService permissions.DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService,
|
||||
|
@ -200,7 +200,6 @@ var wireBasicSet = wire.NewSet(
|
||||
search.ProvideService,
|
||||
searchV2.ProvideService,
|
||||
store.ProvideService,
|
||||
store.ProvideHTTPService,
|
||||
export.ProvideService,
|
||||
live.ProvideService,
|
||||
pushhttp.ProvideService,
|
||||
|
@ -136,9 +136,10 @@ var (
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
{
|
||||
Name: "dashboardsFromStorage",
|
||||
Description: "Load dashboards from the generic storage interface",
|
||||
State: FeatureStateAlpha,
|
||||
Name: "dashboardsFromStorage",
|
||||
Description: "Load dashboards from the generic storage interface",
|
||||
State: FeatureStateAlpha,
|
||||
RequiresDevMode: true, // Also a gate on automatic git storage (for now)
|
||||
},
|
||||
{
|
||||
Name: "export",
|
||||
|
@ -1,10 +1,110 @@
|
||||
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 string `json:"type"`
|
||||
Prefix string `json:"prefix"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
|
||||
// Depending on type, these will be configured
|
||||
Disk *StorageLocalDiskConfig `json:"disk,omitempty"`
|
||||
@ -26,7 +126,8 @@ type StorageGitConfig struct {
|
||||
|
||||
// Pull interval?
|
||||
// Requires pull request?
|
||||
RequirePullRequest bool `json:"requirePullRequest"`
|
||||
RequirePullRequest bool `json:"requirePullRequest"`
|
||||
PullInterval string `json:"pullInterval"`
|
||||
|
||||
// SECURE JSON :grimicing:
|
||||
AccessToken string `json:"accessToken,omitempty"` // Simplest auth method for github
|
||||
@ -52,3 +153,14 @@ type StorageGCSConfig struct {
|
||||
|
||||
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"
|
||||
|
||||
"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/services/quota"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"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 {
|
||||
switch {
|
||||
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
|
||||
quotaReached, err := s.quotaService.CheckQuotaReached(c.Req.Context(), "file", nil)
|
||||
if err != nil {
|
||||
@ -127,7 +137,7 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
|
||||
entityType = EntityTypeImage
|
||||
}
|
||||
|
||||
err = s.store.Upload(c.Req.Context(), c.SignedInUser, &UploadRequest{
|
||||
err = s.Upload(c.Req.Context(), c.SignedInUser, &UploadRequest{
|
||||
Contents: data,
|
||||
EntityType: entityType,
|
||||
Path: path,
|
||||
@ -157,10 +167,10 @@ func getMultipartFormValue(req *http.Request, key string) string {
|
||||
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
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
_, 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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
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)
|
||||
return req.Contents, nil
|
||||
} else {
|
||||
|
@ -1,17 +1,22 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/infra/filestorage"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"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/setting"
|
||||
)
|
||||
@ -24,6 +29,7 @@ var ErrValidationFailed = errors.New("request validation failed")
|
||||
var ErrFileAlreadyExists = errors.New("file exists")
|
||||
var ErrStorageNotFound = errors.New("storage not found")
|
||||
var ErrAccessDenied = errors.New("access denied")
|
||||
var ErrOnlyDashboardSaveSupported = errors.New("only dashboard save is currently supported")
|
||||
|
||||
const RootPublicStatic = "public-static"
|
||||
const RootResources = "resources"
|
||||
@ -52,6 +58,9 @@ type CreateFolderCmd struct {
|
||||
type StorageService interface {
|
||||
registry.BackgroundService
|
||||
|
||||
// Register the HTTP
|
||||
RegisterHTTPRoutes(routing.RouteRegister)
|
||||
|
||||
// List folder contents
|
||||
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)
|
||||
}
|
||||
|
||||
type storageServiceConfig struct {
|
||||
allowUnsanitizedSvgUpload bool
|
||||
}
|
||||
|
||||
type standardStorageService struct {
|
||||
sql *sqlstore.SQLStore
|
||||
tree *nestedTree
|
||||
cfg storageServiceConfig
|
||||
authService storageAuthService
|
||||
sql *sqlstore.SQLStore
|
||||
tree *nestedTree
|
||||
cfg *GlobalStorageConfig
|
||||
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{
|
||||
newDiskStorage(RootStorageConfig{
|
||||
newDiskStorage(RootStorageMeta{
|
||||
ReadOnly: true,
|
||||
Builtin: true,
|
||||
}, RootStorageConfig{
|
||||
Prefix: RootPublicStatic,
|
||||
Name: "Public static files",
|
||||
Description: "Access files from the static public files",
|
||||
@ -98,14 +118,16 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
|
||||
"/maps/",
|
||||
},
|
||||
},
|
||||
}).setReadOnly(true).setBuiltin(true),
|
||||
}),
|
||||
}
|
||||
|
||||
// Development dashboards
|
||||
if setting.Env != setting.Prod {
|
||||
if settings.AddDevEnv && setting.Env != setting.Prod {
|
||||
devenv := filepath.Join(cfg.StaticRootPath, "..", "devenv")
|
||||
if _, err := os.Stat(devenv); !os.IsNotExist(err) {
|
||||
s := newDiskStorage(RootStorageConfig{
|
||||
s := newDiskStorage(RootStorageMeta{
|
||||
ReadOnly: false,
|
||||
}, RootStorageConfig{
|
||||
Prefix: RootDevenv,
|
||||
Name: "Development Environment",
|
||||
Description: "Explore files within the developer environment directly",
|
||||
@ -114,8 +136,21 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
|
||||
Roots: []string{
|
||||
"/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)
|
||||
}
|
||||
}
|
||||
@ -125,19 +160,21 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
|
||||
|
||||
// Custom upload files
|
||||
storages = append(storages,
|
||||
newSQLStorage(RootResources,
|
||||
newSQLStorage(RootStorageMeta{
|
||||
Builtin: true,
|
||||
}, RootResources,
|
||||
"Resources",
|
||||
"Upload custom resource files",
|
||||
&StorageSQLConfig{}, sql, orgId).
|
||||
setBuiltin(true))
|
||||
&StorageSQLConfig{}, sql, orgId))
|
||||
|
||||
// System settings
|
||||
storages = append(storages,
|
||||
newSQLStorage(RootSystem,
|
||||
newSQLStorage(RootStorageMeta{
|
||||
Builtin: true,
|
||||
}, RootResources,
|
||||
"System",
|
||||
"Grafana system storage",
|
||||
&StorageSQLConfig{}, sql, orgId).
|
||||
setBuiltin(true))
|
||||
&StorageSQLConfig{}, sql, orgId))
|
||||
|
||||
return storages
|
||||
}
|
||||
@ -179,25 +216,18 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
|
||||
return nil
|
||||
}
|
||||
|
||||
switch storageName {
|
||||
case RootDevenv:
|
||||
return map[string]filestorage.PathFilter{
|
||||
ActionFilesRead: allowAllPathFilter,
|
||||
ActionFilesWrite: denyAllPathFilter,
|
||||
ActionFilesDelete: denyAllPathFilter,
|
||||
}
|
||||
case RootResources:
|
||||
return map[string]filestorage.PathFilter{
|
||||
ActionFilesRead: allowAllPathFilter,
|
||||
ActionFilesWrite: allowAllPathFilter,
|
||||
ActionFilesDelete: allowAllPathFilter,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
// Admin can do anything
|
||||
return map[string]filestorage.PathFilter{
|
||||
ActionFilesRead: allowAllPathFilter,
|
||||
ActionFilesWrite: allowAllPathFilter,
|
||||
ActionFilesDelete: allowAllPathFilter,
|
||||
}
|
||||
})
|
||||
|
||||
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 {
|
||||
@ -208,7 +238,13 @@ func createSystemBrandingPathFilter() filestorage.PathFilter {
|
||||
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[ac.GlobalOrgID] = globalRoots
|
||||
|
||||
@ -221,9 +257,6 @@ func newStandardStorageService(sql *sqlstore.SQLStore, globalRoots []storageRunt
|
||||
sql: sql,
|
||||
tree: res,
|
||||
authService: authService,
|
||||
cfg: storageServiceConfig{
|
||||
allowUnsanitizedSvgUpload: cfg.Storage.AllowUnsanitizedSvgUpload,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -377,3 +410,86 @@ func (s *standardStorageService) Delete(ctx context.Context, user *models.Signed
|
||||
}
|
||||
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")
|
||||
publicStaticFilesStorage = newDiskStorage(RootStorageConfig{
|
||||
Prefix: "public",
|
||||
Name: "Public static files",
|
||||
Disk: &StorageLocalDiskConfig{
|
||||
Path: publicRoot,
|
||||
Roots: []string{
|
||||
"/testdata/",
|
||||
"/img/icons/",
|
||||
"/img/bg/",
|
||||
"/gazetteer/",
|
||||
"/maps/",
|
||||
"/upload/",
|
||||
},
|
||||
}}).setReadOnly(true).setBuiltin(true)
|
||||
publicStaticFilesStorage = newDiskStorage(
|
||||
RootStorageMeta{
|
||||
Builtin: true,
|
||||
ReadOnly: true,
|
||||
}, RootStorageConfig{
|
||||
Prefix: "public",
|
||||
Name: "Public static files",
|
||||
Disk: &StorageLocalDiskConfig{
|
||||
Path: publicRoot,
|
||||
Roots: []string{
|
||||
"/testdata/",
|
||||
"/img/icons/",
|
||||
"/img/bg/",
|
||||
"/gazetteer/",
|
||||
"/maps/",
|
||||
"/upload/",
|
||||
},
|
||||
}})
|
||||
)
|
||||
|
||||
func TestListFiles(t *testing.T) {
|
||||
@ -96,6 +100,7 @@ func setupUploadStore(t *testing.T, authService storageAuthService) (StorageServ
|
||||
storageName := "resources"
|
||||
mockStorage := &filestorage.MockFileStorage{}
|
||||
sqlStorage := newSQLStorage(
|
||||
RootStorageMeta{},
|
||||
storageName, "Testing upload", "dummy descr",
|
||||
&StorageSQLConfig{},
|
||||
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 {
|
||||
return make([]storageRuntime, 0)
|
||||
}, authService, cfg)
|
||||
store.cfg = &GlobalStorageConfig{
|
||||
AllowUnsanitizedSvgUpload: true,
|
||||
}
|
||||
|
||||
return store, mockStorage, storageName
|
||||
}
|
||||
|
@ -12,23 +12,22 @@ import (
|
||||
|
||||
const rootStorageTypeDisk = "disk"
|
||||
|
||||
type rootStorageDisk struct {
|
||||
baseStorageRuntime
|
||||
var _ storageRuntime = &rootStorageDisk{}
|
||||
|
||||
type rootStorageDisk struct {
|
||||
settings *StorageLocalDiskConfig
|
||||
meta RootStorageMeta
|
||||
store filestorage.FileStorage
|
||||
}
|
||||
|
||||
func newDiskStorage(scfg RootStorageConfig) *rootStorageDisk {
|
||||
func newDiskStorage(meta RootStorageMeta, scfg RootStorageConfig) *rootStorageDisk {
|
||||
cfg := scfg.Disk
|
||||
if cfg == nil {
|
||||
cfg = &StorageLocalDiskConfig{}
|
||||
scfg.Disk = cfg
|
||||
}
|
||||
scfg.Type = rootStorageTypeDisk
|
||||
|
||||
meta := RootStorageMeta{
|
||||
Config: scfg,
|
||||
}
|
||||
meta.Config = scfg
|
||||
if scfg.Prefix == "" {
|
||||
meta.Notice = append(meta.Notice, data.Notice{
|
||||
Severity: data.NoticeSeverityError,
|
||||
@ -42,7 +41,9 @@ func newDiskStorage(scfg RootStorageConfig) *rootStorageDisk {
|
||||
})
|
||||
}
|
||||
|
||||
s := &rootStorageDisk{}
|
||||
s := &rootStorageDisk{
|
||||
settings: cfg,
|
||||
}
|
||||
|
||||
if meta.Notice == nil {
|
||||
path := fmt.Sprintf("file://%s", cfg.Path)
|
||||
@ -63,10 +64,17 @@ func newDiskStorage(scfg RootStorageConfig) *rootStorageDisk {
|
||||
}
|
||||
|
||||
s.meta = meta
|
||||
s.settings = cfg
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *rootStorageDisk) Meta() RootStorageMeta {
|
||||
return s.meta
|
||||
}
|
||||
|
||||
func (s *rootStorageDisk) Store() filestorage.FileStorage {
|
||||
return s.store
|
||||
}
|
||||
|
||||
func (s *rootStorageDisk) Sync() error {
|
||||
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"
|
||||
|
||||
type rootStorageSQL struct {
|
||||
baseStorageRuntime
|
||||
var _ storageRuntime = &rootStorageSQL{}
|
||||
|
||||
type rootStorageSQL struct {
|
||||
settings *StorageSQLConfig
|
||||
meta RootStorageMeta
|
||||
store filestorage.FileStorage
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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 {
|
||||
cfg = &StorageSQLConfig{}
|
||||
}
|
||||
|
||||
meta := RootStorageMeta{
|
||||
Config: RootStorageConfig{
|
||||
Type: rootStorageTypeSQL,
|
||||
Prefix: prefix,
|
||||
Name: name,
|
||||
Description: descr,
|
||||
SQL: cfg,
|
||||
},
|
||||
meta.Config = RootStorageConfig{
|
||||
Type: rootStorageTypeSQL,
|
||||
Prefix: prefix,
|
||||
Name: name,
|
||||
Description: descr,
|
||||
SQL: cfg,
|
||||
}
|
||||
|
||||
if prefix == "" {
|
||||
@ -78,6 +78,14 @@ func (s *rootStorageSQL) Write(ctx context.Context, cmd *WriteValueRequest) (*Wr
|
||||
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 {
|
||||
return nil // already in sync
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"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 == "" {
|
||||
return nil, nil // not found
|
||||
}
|
||||
|
||||
root, path := t.getRoot(orgId, path)
|
||||
if root == nil {
|
||||
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) {
|
||||
@ -98,26 +102,17 @@ func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string, a
|
||||
names := data.NewFieldFromFieldType(data.FieldTypeString, count)
|
||||
title := 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)
|
||||
title.Name = titleListFrameField
|
||||
names.Name = nameListFrameField
|
||||
descr.Name = descriptionListFrameField
|
||||
mtype.Name = mediaTypeListFrameField
|
||||
types.Name = storageTypeListFrameField
|
||||
readOnly.Name = readOnlyListFrameField
|
||||
builtIn.Name = builtInListFrameField
|
||||
for _, f := range t.rootsByOrgId[ac.GlobalOrgID] {
|
||||
meta := f.Meta()
|
||||
names.Set(idx, meta.Config.Prefix)
|
||||
title.Set(idx, meta.Config.Name)
|
||||
descr.Set(idx, meta.Config.Description)
|
||||
mtype.Set(idx, "directory")
|
||||
types.Set(idx, meta.Config.Type)
|
||||
readOnly.Set(idx, meta.ReadOnly)
|
||||
builtIn.Set(idx, meta.Builtin)
|
||||
idx++
|
||||
}
|
||||
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)
|
||||
descr.Set(idx, meta.Config.Description)
|
||||
mtype.Set(idx, "directory")
|
||||
types.Set(idx, meta.Config.Type)
|
||||
readOnly.Set(idx, meta.ReadOnly)
|
||||
builtIn.Set(idx, meta.Builtin)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
frame := data.NewFrame("", names, title, descr, mtype, types, readOnly, builtIn)
|
||||
frame := data.NewFrame("", names, title, descr, mtype)
|
||||
frame.SetMeta(&data.FrameMeta{
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
WithFolders: true,
|
||||
WithFiles: true,
|
||||
|
@ -9,13 +9,22 @@ import (
|
||||
"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 {
|
||||
Path string
|
||||
User *models.SignedInUser
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Title string `json:"title,omitempty"` // For PRs
|
||||
Action string `json:"action,omitempty"` // pr | save
|
||||
User *models.SignedInUser
|
||||
Path string // added from URL
|
||||
EntityType EntityType `json:"kind,omitempty"` // for now only dashboard
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Title string `json:"title,omitempty"` // For PRs
|
||||
Workflow WriteValueWorkflow `json:"workflow,omitempty"` // save | pr | push
|
||||
}
|
||||
|
||||
type WriteValueResponse struct {
|
||||
@ -48,40 +57,6 @@ type storageRuntime interface {
|
||||
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 {
|
||||
ReadOnly bool `json:"editable,omitempty"`
|
||||
Builtin bool `json:"builtin,omitempty"`
|
||||
@ -100,9 +75,6 @@ const (
|
||||
nameListFrameField = "name"
|
||||
descriptionListFrameField = "description"
|
||||
mediaTypeListFrameField = "mediaType"
|
||||
storageTypeListFrameField = "storageType"
|
||||
readOnlyListFrameField = "readOnly"
|
||||
builtInListFrameField = "builtIn"
|
||||
sizeListFrameField = "size"
|
||||
)
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Drawer, Spinner, Tab, TabsBar } from '@grafana/ui';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
@ -11,14 +12,16 @@ import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
|
||||
import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm';
|
||||
import { SaveDashboardForm } from './forms/SaveDashboardForm';
|
||||
import { SaveProvisionedDashboardForm } from './forms/SaveProvisionedDashboardForm';
|
||||
import { SaveToStorageForm } from './forms/SaveToStorageForm';
|
||||
import { SaveDashboardData, SaveDashboardModalProps, SaveDashboardOptions } from './types';
|
||||
import { useDashboardSave } from './useDashboardSave';
|
||||
|
||||
export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCopy }: SaveDashboardModalProps) => {
|
||||
const [options, setOptions] = useState<SaveDashboardOptions>({});
|
||||
|
||||
const isProvisioned = dashboard.meta.provisioned;
|
||||
const isNew = dashboard.version === 0;
|
||||
const isFromStorage = config.featureToggles.dashboardsFromStorage && dashboard.uid.indexOf('/') > 0;
|
||||
const isProvisioned = dashboard.meta.provisioned && !isFromStorage;
|
||||
const isNew = dashboard.version === 0 && !isFromStorage;
|
||||
|
||||
const previous = useAsync(async () => {
|
||||
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) {
|
||||
return (
|
||||
<SaveDashboardAsForm
|
||||
|
@ -10,7 +10,7 @@ interface FormDTO {
|
||||
message: string;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
export type SaveProps = {
|
||||
dashboard: DashboardModel; // original
|
||||
saveModel: SaveDashboardData; // already cloned
|
||||
onCancel: () => void;
|
||||
@ -28,7 +28,7 @@ export const SaveDashboardForm = ({
|
||||
onCancel,
|
||||
onSuccess,
|
||||
onOptionsChange,
|
||||
}: Props) => {
|
||||
}: SaveProps) => {
|
||||
const hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [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 { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
import { getGrafanaStorage } from 'app/features/storage/storage';
|
||||
import { DashboardDTO, FolderInfo, PermissionLevelString, ThunkResult } from 'app/types';
|
||||
|
||||
import { LibraryElementExport } from '../../dashboard/components/DashExportModal/DashboardExporter';
|
||||
@ -267,10 +266,6 @@ export function deleteFoldersAndDashboards(folderUids: string[], dashboardUids:
|
||||
export function saveDashboard(options: SaveDashboardCommand) {
|
||||
dashboardWatcher.ignoreNextSave();
|
||||
|
||||
if (options.dashboard.uid.indexOf('/') > 0) {
|
||||
return getGrafanaStorage().saveDashboard(options);
|
||||
}
|
||||
|
||||
return getBackendSrv().post('/api/dashboards/db/', {
|
||||
dashboard: options.dashboard,
|
||||
message: options.message ?? '',
|
||||
|
@ -1,28 +1,33 @@
|
||||
import { css } from '@emotion/css';
|
||||
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 { 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 {
|
||||
root: DataFrame;
|
||||
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) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const storage = useAsync(getGrafanaStorage().getConfig);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
let base = location.pathname;
|
||||
if (!base.endsWith('/')) {
|
||||
@ -30,11 +35,11 @@ export function RootView({ root, onPathChange }: Props) {
|
||||
}
|
||||
|
||||
const roots = useMemo(() => {
|
||||
const view = new DataFrameView<RootFolder>(root);
|
||||
const all = view.map((v) => ({ ...v }));
|
||||
if (searchQuery?.length) {
|
||||
const all = storage.value;
|
||||
if (searchQuery?.length && all) {
|
||||
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;
|
||||
if (isMatch) {
|
||||
return true;
|
||||
@ -42,8 +47,8 @@ export function RootView({ root, onPathChange }: Props) {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return all;
|
||||
}, [searchQuery, root]);
|
||||
return all ?? [];
|
||||
}, [searchQuery, storage]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -61,18 +66,30 @@ export function RootView({ root, onPathChange }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<VerticalGroup>
|
||||
{roots.map((v) => (
|
||||
<Card key={v.name} href={`admin/storage/${v.name}/`}>
|
||||
<Card.Heading>{v.title ?? v.name}</Card.Heading>
|
||||
<Card.Meta className={styles.clickable}>{v.description}</Card.Meta>
|
||||
<Card.Tags className={styles.clickable}>
|
||||
<TagList tags={getTags(v)} />
|
||||
</Card.Tags>
|
||||
<Card.Figure className={styles.clickable}>
|
||||
<Icon name={getIconName(v.storageType)} size="xxxl" className={styles.secondaryTextColor} />
|
||||
</Card.Figure>
|
||||
</Card>
|
||||
))}
|
||||
{roots.map((s) => {
|
||||
const ok = s.ready;
|
||||
return (
|
||||
<Card key={s.config.prefix} href={ok ? `admin/storage/${s.config.prefix}/` : undefined}>
|
||||
<Card.Heading>{s.config.name}</Card.Heading>
|
||||
<Card.Meta className={styles.clickable}>
|
||||
{s.config.description}
|
||||
{s.config.git?.remote && <a href={s.config.git?.remote}>{s.config.git?.remote}</a>}
|
||||
</Card.Meta>
|
||||
{s.notice?.map((notice) => (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
@ -89,14 +106,19 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
};
|
||||
}
|
||||
|
||||
function getTags(v: RootFolder) {
|
||||
function getTags(v: StorageInfo) {
|
||||
const tags: string[] = [];
|
||||
if (v.builtIn) {
|
||||
if (v.builtin) {
|
||||
tags.push('Builtin');
|
||||
}
|
||||
if (v.readOnly) {
|
||||
if (!v.editable) {
|
||||
tags.push('Read only');
|
||||
}
|
||||
|
||||
// Error
|
||||
if (!v.ready) {
|
||||
tags.push('Not ready');
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
|
@ -157,8 +157,7 @@ export default function StoragePage(props: Props) {
|
||||
|
||||
const canAddFolder = isFolder && path.startsWith('resources');
|
||||
const canDelete = path.startsWith('resources/');
|
||||
const canViewDashboard =
|
||||
path.startsWith('devenv/') && config.featureToggles.dashboardsFromStorage && (isFolder || path.endsWith('.json'));
|
||||
const canViewDashboard = config.featureToggles.dashboardsFromStorage && (isFolder || path.endsWith('.json'));
|
||||
|
||||
const getErrorMessages = () => {
|
||||
return (
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { DataFrame, dataFrameFromJSON, DataFrameJSON, getDisplayProcessor } from '@grafana/data';
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/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!
|
||||
export interface GrafanaStorage {
|
||||
@ -14,12 +13,20 @@ export interface GrafanaStorage {
|
||||
createFolder: (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
|
||||
* Longer term, this will call an "Entity API" that is eventually backed by storage
|
||||
*/
|
||||
getDashboard: (path: string) => Promise<DashboardDTO>;
|
||||
saveDashboard: (options: SaveDashboardCommand) => Promise<any>;
|
||||
|
||||
/** Saves dashbaords */
|
||||
write: (path: string, options: WriteValueRequest) => Promise<WriteValueResponse>;
|
||||
}
|
||||
|
||||
class SimpleStorage implements GrafanaStorage {
|
||||
@ -140,40 +147,16 @@ class SimpleStorage implements GrafanaStorage {
|
||||
};
|
||||
}
|
||||
|
||||
async saveDashboard(options: SaveDashboardCommand): Promise<any> {
|
||||
if (!config.featureToggles.dashboardsFromStorage) {
|
||||
return Promise.reject('Dashboards from storage is not enabled');
|
||||
}
|
||||
async write(path: string, options: WriteValueRequest): Promise<WriteValueResponse> {
|
||||
return backendSrv.post<WriteValueResponse>(`/api/storage/write/${path}`, options);
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(options.dashboard)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
async getConfig() {
|
||||
return getBackendSrv().get<StorageInfo[]>('/api/storage/config');
|
||||
}
|
||||
|
||||
const uid = options.dashboard.uid;
|
||||
const formData = new FormData();
|
||||
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',
|
||||
};
|
||||
async getOptions(path: string) {
|
||||
return getBackendSrv().get<ItemOptions>(`/api/storage/options/${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { QueryResultMetaNotice, SelectableValue } from '@grafana/data';
|
||||
|
||||
export enum StorageView {
|
||||
Data = 'data',
|
||||
Config = 'config',
|
||||
@ -15,3 +17,58 @@ export interface UploadReponse {
|
||||
message: 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