mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 05:01:50 +08:00
Admin: Add support bundles (#60536)
* Add support bundles Co-authored-by: ievaVasiljeva <ieva.vasiljeva@grafana.com> Co-authored-by: Kalle Persson <kalle.persson@grafana.com> * tweak code owners * rename and lint frontend * lint * fix backend lint * register feature flag * add feature toggle. fix small backend issues Co-authored-by: ievaVasiljeva <ieva.vasiljeva@grafana.com> Co-authored-by: Kalle Persson <kalle.persson@grafana.com>
This commit is contained in:
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@ -229,6 +229,10 @@ lerna.json @grafana/frontend-ops
|
|||||||
/pkg/services/loginattempt @grafana/grafana-authnz-team
|
/pkg/services/loginattempt @grafana/grafana-authnz-team
|
||||||
/pkg/services/authn @grafana/grafana-authnz-team
|
/pkg/services/authn @grafana/grafana-authnz-team
|
||||||
|
|
||||||
|
# Support bundles
|
||||||
|
/public/app/features/support-bundles @grafana/grafana-authnz-team
|
||||||
|
/pkg/infra/supportbundles @grafana/grafana-authnz-team
|
||||||
|
|
||||||
# Grafana Partnerships Team
|
# Grafana Partnerships Team
|
||||||
/pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go @grafana/grafana-partnerships-team
|
/pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go @grafana/grafana-partnerships-team
|
||||||
/pkg/infra/httpclient/httpclientprovider/sigv4_middleware_test.go @grafana/grafana-partnerships-team
|
/pkg/infra/httpclient/httpclientprovider/sigv4_middleware_test.go @grafana/grafana-partnerships-team
|
||||||
|
@ -67,6 +67,7 @@ Alpha features might be changed or removed without prior notice.
|
|||||||
| `dashboardComments` | Enable dashboard-wide comments |
|
| `dashboardComments` | Enable dashboard-wide comments |
|
||||||
| `annotationComments` | Enable annotation comments |
|
| `annotationComments` | Enable annotation comments |
|
||||||
| `storage` | Configurable storage for dashboards, datasources, and resources |
|
| `storage` | Configurable storage for dashboards, datasources, and resources |
|
||||||
|
| `supportBundles` | Support bundles for troubleshooting |
|
||||||
| `exploreMixedDatasource` | Enable mixed datasource in Explore |
|
| `exploreMixedDatasource` | Enable mixed datasource in Explore |
|
||||||
| `tracing` | Adds trace ID to error notifications |
|
| `tracing` | Adds trace ID to error notifications |
|
||||||
| `correlations` | Correlations page |
|
| `correlations` | Correlations page |
|
||||||
|
@ -43,6 +43,7 @@ export interface FeatureToggles {
|
|||||||
migrationLocking?: boolean;
|
migrationLocking?: boolean;
|
||||||
storage?: boolean;
|
storage?: boolean;
|
||||||
k8s?: boolean;
|
k8s?: boolean;
|
||||||
|
supportBundles?: boolean;
|
||||||
dashboardsFromStorage?: boolean;
|
dashboardsFromStorage?: boolean;
|
||||||
export?: boolean;
|
export?: boolean;
|
||||||
azureMonitorResourcePickerForMetrics?: boolean;
|
azureMonitorResourcePickerForMetrics?: boolean;
|
||||||
|
@ -121,6 +121,9 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
}
|
}
|
||||||
r.Get("/styleguide", reqSignedIn, hs.Index)
|
r.Get("/styleguide", reqSignedIn, hs.Index)
|
||||||
|
|
||||||
|
r.Get("/admin/support-bundles", reqGrafanaAdmin, hs.Index)
|
||||||
|
r.Get("/admin/support-bundles/create", reqGrafanaAdmin, hs.Index)
|
||||||
|
|
||||||
r.Get("/live", reqGrafanaAdmin, hs.Index)
|
r.Get("/live", reqGrafanaAdmin, hs.Index)
|
||||||
r.Get("/live/pipeline", reqGrafanaAdmin, hs.Index)
|
r.Get("/live/pipeline", reqGrafanaAdmin, hs.Index)
|
||||||
r.Get("/live/cloud", reqGrafanaAdmin, hs.Index)
|
r.Get("/live/cloud", reqGrafanaAdmin, hs.Index)
|
||||||
|
45
pkg/infra/supportbundles/interface.go
Normal file
45
pkg/infra/supportbundles/interface.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package supportbundles
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type SupportItem struct {
|
||||||
|
Filename string
|
||||||
|
FileBytes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type State string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatePending State = "pending"
|
||||||
|
StateComplete State = "complete"
|
||||||
|
StateError State = "error"
|
||||||
|
StateTimeout State = "timeout"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s State) String() string {
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bundle struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
State State `json:"state"`
|
||||||
|
FilePath string `json:"filePath"`
|
||||||
|
Creator string `json:"creator"`
|
||||||
|
CreatedAt int64 `json:"createdAt"`
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollectorFunc func(context.Context) (*SupportItem, error)
|
||||||
|
|
||||||
|
type Collector struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IncludedByDefault bool `json:"includedByDefault"`
|
||||||
|
Default bool `json:"default"`
|
||||||
|
Fn CollectorFunc `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
RegisterSupportItemCollector(collector Collector)
|
||||||
|
}
|
115
pkg/infra/supportbundles/supportbundlesimpl/api.go
Normal file
115
pkg/infra/supportbundles/supportbundlesimpl/api.go
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
package supportbundlesimpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/api/response"
|
||||||
|
"github.com/grafana/grafana/pkg/api/routing"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||||
|
"github.com/grafana/grafana/pkg/middleware"
|
||||||
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
const rootUrl = "/api/support-bundles"
|
||||||
|
|
||||||
|
func (s *Service) registerAPIEndpoints(routeRegister routing.RouteRegister) {
|
||||||
|
authorize := ac.Middleware(s.accessControl)
|
||||||
|
|
||||||
|
routeRegister.Group(rootUrl, func(subrouter routing.RouteRegister) {
|
||||||
|
subrouter.Get("/", authorize(middleware.ReqGrafanaAdmin,
|
||||||
|
ac.EvalPermission(ActionRead)), routing.Wrap(s.handleList))
|
||||||
|
subrouter.Post("/", authorize(middleware.ReqGrafanaAdmin,
|
||||||
|
ac.EvalPermission(ActionCreate)), routing.Wrap(s.handleCreate))
|
||||||
|
subrouter.Get("/:uid", authorize(middleware.ReqGrafanaAdmin,
|
||||||
|
ac.EvalPermission(ActionRead)), s.handleDownload)
|
||||||
|
subrouter.Delete("/:uid", authorize(middleware.ReqGrafanaAdmin,
|
||||||
|
ac.EvalPermission(ActionDelete)), s.handleRemove)
|
||||||
|
subrouter.Get("/collectors", authorize(middleware.ReqGrafanaAdmin,
|
||||||
|
ac.EvalPermission(ActionCreate)), routing.Wrap(s.handleGetCollectors))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleList(ctx *models.ReqContext) response.Response {
|
||||||
|
bundles, err := s.List(ctx.Req.Context())
|
||||||
|
if err != nil {
|
||||||
|
return response.Error(http.StatusInternalServerError, "failed to list bundles", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(bundles)
|
||||||
|
if err != nil {
|
||||||
|
return response.Error(http.StatusInternalServerError, "failed to encode bundle", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(http.StatusOK, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleCreate(ctx *models.ReqContext) response.Response {
|
||||||
|
type command struct {
|
||||||
|
Collectors []string `json:"collectors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var c command
|
||||||
|
if err := web.Bind(ctx.Req, &c); err != nil {
|
||||||
|
return response.Error(http.StatusBadRequest, "failed to parse request", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle, err := s.Create(context.Background(), c.Collectors, ctx.SignedInUser)
|
||||||
|
if err != nil {
|
||||||
|
return response.Error(http.StatusInternalServerError, "failed to create support bundle", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(bundle)
|
||||||
|
if err != nil {
|
||||||
|
return response.Error(http.StatusInternalServerError, "failed to encode bundle", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(http.StatusCreated, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleDownload(ctx *models.ReqContext) {
|
||||||
|
uid := web.Params(ctx.Req)[":uid"]
|
||||||
|
bundle, err := s.Get(ctx.Req.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Redirect("/admin/support-bundles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if bundle.State != supportbundles.StateComplete {
|
||||||
|
ctx.Redirect("/admin/support-bundles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if bundle.FilePath == "" {
|
||||||
|
ctx.Redirect("/admin/support-bundles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(bundle.FilePath); err != nil {
|
||||||
|
ctx.Redirect("/admin/support-bundles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Resp.Header().Set("Content-Type", "application/tar+gzip")
|
||||||
|
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%d.tar.gz", bundle.CreatedAt))
|
||||||
|
http.ServeFile(ctx.Resp, ctx.Req, bundle.FilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleRemove(ctx *models.ReqContext) response.Response {
|
||||||
|
uid := web.Params(ctx.Req)[":uid"]
|
||||||
|
err := s.Remove(ctx.Req.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
return response.Error(http.StatusInternalServerError, "failed to remove bundle", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Respond(http.StatusOK, "successfully removed the support bundle")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleGetCollectors(ctx *models.ReqContext) response.Response {
|
||||||
|
return response.JSON(http.StatusOK, s.collectors)
|
||||||
|
}
|
162
pkg/infra/supportbundles/supportbundlesimpl/collectors.go
Normal file
162
pkg/infra/supportbundles/supportbundlesimpl/collectors.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package supportbundlesimpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
|
"github.com/grafana/grafana/pkg/services/pluginsettings"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
func basicCollector(cfg *setting.Cfg) supportbundles.Collector {
|
||||||
|
return supportbundles.Collector{
|
||||||
|
UID: "basic",
|
||||||
|
DisplayName: "Basic information",
|
||||||
|
Description: "Basic information about the Grafana instance",
|
||||||
|
IncludedByDefault: true,
|
||||||
|
Default: true,
|
||||||
|
Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
||||||
|
type basicInfo struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(basicInfo{
|
||||||
|
Version: cfg.BuildVersion,
|
||||||
|
Commit: cfg.BuildCommit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &supportbundles.SupportItem{
|
||||||
|
Filename: "basic.json",
|
||||||
|
FileBytes: data,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsCollector(settings setting.Provider) supportbundles.Collector {
|
||||||
|
return supportbundles.Collector{
|
||||||
|
UID: "settings",
|
||||||
|
DisplayName: "Settings",
|
||||||
|
Description: "Settings for grafana instance",
|
||||||
|
IncludedByDefault: false,
|
||||||
|
Default: true,
|
||||||
|
Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
||||||
|
current := settings.Current()
|
||||||
|
data, err := json.Marshal(current)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &supportbundles.SupportItem{
|
||||||
|
Filename: "settings.json",
|
||||||
|
FileBytes: data,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageStatesCollector(stats usagestats.Service) supportbundles.Collector {
|
||||||
|
return supportbundles.Collector{
|
||||||
|
UID: "usage-stats",
|
||||||
|
DisplayName: "Usage statistics",
|
||||||
|
Description: "Usage statistic for grafana instance",
|
||||||
|
IncludedByDefault: false,
|
||||||
|
Default: true,
|
||||||
|
Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
||||||
|
report, err := stats.GetUsageReport(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(report)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &supportbundles.SupportItem{
|
||||||
|
Filename: "usage-stats.json",
|
||||||
|
FileBytes: data,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginInfoCollector(pluginStore plugins.Store, pluginSettings pluginsettings.Service) supportbundles.Collector {
|
||||||
|
return supportbundles.Collector{
|
||||||
|
UID: "plugins",
|
||||||
|
DisplayName: "Plugin information",
|
||||||
|
Description: "Plugin information for grafana instance",
|
||||||
|
IncludedByDefault: false,
|
||||||
|
Default: true,
|
||||||
|
Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
||||||
|
type pluginInfo struct {
|
||||||
|
data plugins.JSONData
|
||||||
|
Class plugins.Class
|
||||||
|
|
||||||
|
// App fields
|
||||||
|
IncludedInAppID string
|
||||||
|
DefaultNavURL string
|
||||||
|
Pinned bool
|
||||||
|
|
||||||
|
// Signature fields
|
||||||
|
Signature plugins.SignatureStatus
|
||||||
|
|
||||||
|
// SystemJS fields
|
||||||
|
Module string
|
||||||
|
BaseURL string
|
||||||
|
|
||||||
|
PluginVersion string
|
||||||
|
Enabled bool
|
||||||
|
Updated time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins := pluginStore.Plugins(context.Background())
|
||||||
|
|
||||||
|
var pluginInfoList []pluginInfo
|
||||||
|
for _, plugin := range plugins {
|
||||||
|
// skip builtin plugins
|
||||||
|
if plugin.BuiltIn {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pInfo := pluginInfo{
|
||||||
|
data: plugin.JSONData,
|
||||||
|
Class: plugin.Class,
|
||||||
|
IncludedInAppID: plugin.IncludedInAppID,
|
||||||
|
DefaultNavURL: plugin.DefaultNavURL,
|
||||||
|
Pinned: plugin.Pinned,
|
||||||
|
Signature: plugin.Signature,
|
||||||
|
Module: plugin.Module,
|
||||||
|
BaseURL: plugin.BaseURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO need to loop through all the orgs
|
||||||
|
// TODO ignore the error for now, not all plugins have settings
|
||||||
|
settings, err := pluginSettings.GetPluginSettingByPluginID(context.Background(), &pluginsettings.GetByPluginIDArgs{PluginID: plugin.ID, OrgID: 1})
|
||||||
|
if err == nil {
|
||||||
|
pInfo.PluginVersion = settings.PluginVersion
|
||||||
|
pInfo.Enabled = settings.Enabled
|
||||||
|
pInfo.Updated = settings.Updated
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginInfoList = append(pluginInfoList, pInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(pluginInfoList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &supportbundles.SupportItem{
|
||||||
|
Filename: "plugins.json",
|
||||||
|
FileBytes: data,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
75
pkg/infra/supportbundles/supportbundlesimpl/db_collector.go
Normal file
75
pkg/infra/supportbundles/supportbundlesimpl/db_collector.go
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
package supportbundlesimpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dbCollector(sql db.DB) supportbundles.Collector {
|
||||||
|
collectorFn := func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
||||||
|
dbType := string(sql.GetDBType())
|
||||||
|
|
||||||
|
// buffer writer
|
||||||
|
bWriter := bytes.NewBuffer(nil)
|
||||||
|
|
||||||
|
bWriter.WriteString("# Database information\n\n")
|
||||||
|
bWriter.WriteString("dbType: " + dbType + " \n")
|
||||||
|
|
||||||
|
logItems := make([]migrator.MigrationLog, 0)
|
||||||
|
version := []string{}
|
||||||
|
err := sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
rawSQL := ""
|
||||||
|
if dbType == migrator.MySQL {
|
||||||
|
rawSQL = "SELECT @@VERSION"
|
||||||
|
} else if dbType == migrator.Postgres {
|
||||||
|
rawSQL = "SELECT version()"
|
||||||
|
} else if dbType == migrator.SQLite {
|
||||||
|
rawSQL = "SELECT sqlite_version()"
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("unsupported dbType: %s", dbType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sess.Table("migration_log").SQL(rawSQL).Find(&version)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range version {
|
||||||
|
bWriter.WriteString("version: " + v + " \n")
|
||||||
|
}
|
||||||
|
|
||||||
|
errD := sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
return sess.Table("migration_log").Find(&logItems)
|
||||||
|
})
|
||||||
|
if errD != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bWriter.WriteString("\n## Migration Log\n\n")
|
||||||
|
|
||||||
|
for _, logItem := range logItems {
|
||||||
|
bWriter.WriteString(fmt.Sprintf("**migrationId**: %s \nsuccess: %t \nerror: %s \ntimestamp: %s\n\n",
|
||||||
|
logItem.MigrationID, logItem.Success, logItem.Error, logItem.Timestamp.UTC()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &supportbundles.SupportItem{
|
||||||
|
Filename: "db.md",
|
||||||
|
FileBytes: bWriter.Bytes(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return supportbundles.Collector{
|
||||||
|
UID: "db",
|
||||||
|
Description: "Database information and migration log",
|
||||||
|
DisplayName: "Database and Migration information",
|
||||||
|
IncludedByDefault: false,
|
||||||
|
Default: true,
|
||||||
|
Fn: collectorFn,
|
||||||
|
}
|
||||||
|
}
|
49
pkg/infra/supportbundles/supportbundlesimpl/models.go
Normal file
49
pkg/infra/supportbundles/supportbundlesimpl/models.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package supportbundlesimpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionRead = "support.bundles:read"
|
||||||
|
ActionCreate = "support.bundles:create"
|
||||||
|
ActionDelete = "support.bundles:delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
bundleReaderRole = accesscontrol.RoleDTO{
|
||||||
|
Name: "fixed:support.bundles:reader",
|
||||||
|
DisplayName: "Support bundle reader",
|
||||||
|
Description: "List and download support bundles",
|
||||||
|
Group: "Support bundles",
|
||||||
|
Permissions: []accesscontrol.Permission{
|
||||||
|
{Action: ActionRead},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleWriterRole = accesscontrol.RoleDTO{
|
||||||
|
Name: "fixed:support.bundles:writer",
|
||||||
|
DisplayName: "Support bundle writer",
|
||||||
|
Description: "Create, delete, list and download support bundles",
|
||||||
|
Group: "Support bundles",
|
||||||
|
Permissions: []accesscontrol.Permission{
|
||||||
|
{Action: ActionRead},
|
||||||
|
{Action: ActionCreate},
|
||||||
|
{Action: ActionDelete},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func DeclareFixedRoles(ac accesscontrol.Service) error {
|
||||||
|
bundleReader := accesscontrol.RoleRegistration{
|
||||||
|
Role: bundleReaderRole,
|
||||||
|
Grants: []string{string(org.RoleAdmin)},
|
||||||
|
}
|
||||||
|
bundleWriter := accesscontrol.RoleRegistration{
|
||||||
|
Role: bundleWriterRole,
|
||||||
|
Grants: []string{string(org.RoleAdmin)},
|
||||||
|
}
|
||||||
|
|
||||||
|
return ac.DeclareFixedRoles(bundleWriter, bundleReader)
|
||||||
|
}
|
164
pkg/infra/supportbundles/supportbundlesimpl/service.go
Normal file
164
pkg/infra/supportbundles/supportbundlesimpl/service.go
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
package supportbundlesimpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/api/routing"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/kvstore"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
|
"github.com/grafana/grafana/pkg/services/pluginsettings"
|
||||||
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
cfg *setting.Cfg
|
||||||
|
store *store
|
||||||
|
pluginStore plugins.Store
|
||||||
|
pluginSettings pluginsettings.Service
|
||||||
|
accessControl ac.AccessControl
|
||||||
|
features *featuremgmt.FeatureManager
|
||||||
|
|
||||||
|
log log.Logger
|
||||||
|
|
||||||
|
collectors []supportbundles.Collector
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProvideService(cfg *setting.Cfg,
|
||||||
|
sql db.DB,
|
||||||
|
kvStore kvstore.KVStore,
|
||||||
|
accessControl ac.AccessControl,
|
||||||
|
accesscontrolService ac.Service,
|
||||||
|
routeRegister routing.RouteRegister,
|
||||||
|
userService user.Service,
|
||||||
|
settings setting.Provider,
|
||||||
|
pluginStore plugins.Store,
|
||||||
|
pluginSettings pluginsettings.Service,
|
||||||
|
features *featuremgmt.FeatureManager,
|
||||||
|
usageStats usagestats.Service) (*Service, error) {
|
||||||
|
s := &Service{
|
||||||
|
cfg: cfg,
|
||||||
|
store: newStore(kvStore),
|
||||||
|
pluginStore: pluginStore,
|
||||||
|
pluginSettings: pluginSettings,
|
||||||
|
accessControl: accessControl,
|
||||||
|
features: features,
|
||||||
|
log: log.New("supportbundle.service"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !features.IsEnabled(featuremgmt.FlagSupportBundles) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !accessControl.IsDisabled() {
|
||||||
|
if err := DeclareFixedRoles(accesscontrolService); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.registerAPIEndpoints(routeRegister)
|
||||||
|
|
||||||
|
// TODO: move to relevant services
|
||||||
|
s.RegisterSupportItemCollector(basicCollector(cfg))
|
||||||
|
s.RegisterSupportItemCollector(settingsCollector(settings))
|
||||||
|
s.RegisterSupportItemCollector(usageStatesCollector(usageStats))
|
||||||
|
s.RegisterSupportItemCollector(userCollector(userService))
|
||||||
|
s.RegisterSupportItemCollector(dbCollector(sql))
|
||||||
|
s.RegisterSupportItemCollector(pluginInfoCollector(pluginStore, pluginSettings))
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, collectors []string, usr *user.SignedInUser) (*supportbundles.Bundle, error) {
|
||||||
|
bundle, err := s.store.Create(ctx, usr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(uid string, collectors []string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
s.startBundleWork(ctx, collectors, uid)
|
||||||
|
}(bundle.UID, collectors)
|
||||||
|
|
||||||
|
return bundle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Get(ctx context.Context, uid string) (*supportbundles.Bundle, error) {
|
||||||
|
return s.store.Get(ctx, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context) ([]supportbundles.Bundle, error) {
|
||||||
|
return s.store.List()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Remove(ctx context.Context, uid string) error {
|
||||||
|
// Remove the data
|
||||||
|
bundle, err := s.store.Get(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not retrieve support bundle with uid %s: %w", uid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO handle cases when bundles aren't complete yet
|
||||||
|
if bundle.State == supportbundles.StatePending {
|
||||||
|
return fmt.Errorf("could not remove a support bundle with uid %s as it is still beign cteated", uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bundle.FilePath != "" {
|
||||||
|
if err := os.RemoveAll(filepath.Dir(bundle.FilePath)); err != nil {
|
||||||
|
return fmt.Errorf("could not remove directory for support bundle %s: %w", uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the KV store entry
|
||||||
|
return s.store.Remove(ctx, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RegisterSupportItemCollector(collector supportbundles.Collector) {
|
||||||
|
// FIXME: add check for duplicate UIDs
|
||||||
|
s.collectors = append(s.collectors, collector)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Run(ctx context.Context) error {
|
||||||
|
if !s.features.IsEnabled(featuremgmt.FlagSupportBundles) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
s.cleanup(ctx)
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
s.cleanup(ctx)
|
||||||
|
case <-ctx.Done():
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) cleanup(ctx context.Context) {
|
||||||
|
bundles, err := s.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to list bundles to clean up", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
for _, b := range bundles {
|
||||||
|
if time.Now().Unix() >= b.ExpiresAt {
|
||||||
|
if err := s.Remove(ctx, b.UID); err != nil {
|
||||||
|
s.log.Error("failed to cleanup bundle", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
153
pkg/infra/supportbundles/supportbundlesimpl/service_bundle.go
Normal file
153
pkg/infra/supportbundles/supportbundlesimpl/service_bundle.go
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
package supportbundlesimpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bundleResult struct {
|
||||||
|
path string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) startBundleWork(ctx context.Context, collectors []string, uid string) {
|
||||||
|
result := make(chan bundleResult)
|
||||||
|
go func() {
|
||||||
|
sbFilePath, err := s.bundle(ctx, collectors, uid)
|
||||||
|
if err != nil {
|
||||||
|
result <- bundleResult{err: err}
|
||||||
|
}
|
||||||
|
result <- bundleResult{
|
||||||
|
path: sbFilePath,
|
||||||
|
}
|
||||||
|
close(result)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
s.log.Warn("Context cancelled while collecting support bundle")
|
||||||
|
if err := s.store.Update(ctx, uid, supportbundles.StateTimeout, ""); err != nil {
|
||||||
|
s.log.Error("failed to update bundle after timeout")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case r := <-result:
|
||||||
|
if r.err != nil {
|
||||||
|
if err := s.store.Update(ctx, uid, supportbundles.StateError, ""); err != nil {
|
||||||
|
s.log.Error("failed to update bundle after error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.Update(ctx, uid, supportbundles.StateComplete, r.path); err != nil {
|
||||||
|
s.log.Error("failed to update bundle after completion")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) bundle(ctx context.Context, collectors []string, uid string) (string, error) {
|
||||||
|
lookup := make(map[string]bool, len(collectors))
|
||||||
|
for _, c := range collectors {
|
||||||
|
lookup[c] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
sbDir, err := os.MkdirTemp("", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, collector := range s.collectors {
|
||||||
|
if !lookup[collector.UID] && !collector.IncludedByDefault {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item, err := collector.Fn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("Failed to collect support bundle item", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// write item to file
|
||||||
|
if item != nil {
|
||||||
|
if err := os.WriteFile(filepath.Join(sbDir, item.Filename), item.FileBytes, 0600); err != nil {
|
||||||
|
s.log.Warn("Failed to collect support bundle item", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create tar.gz file
|
||||||
|
var buf bytes.Buffer
|
||||||
|
errCompress := compress(sbDir, &buf)
|
||||||
|
if errCompress != nil {
|
||||||
|
return "", errCompress
|
||||||
|
}
|
||||||
|
|
||||||
|
finalFilePath := filepath.Join(sbDir, fmt.Sprintf("%s.tar.gz", uid))
|
||||||
|
|
||||||
|
// Ignore gosec G304 as this function is only used internally.
|
||||||
|
//nolint:gosec
|
||||||
|
fileToWrite, err := os.OpenFile(finalFilePath, os.O_CREATE|os.O_RDWR, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(fileToWrite, &buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalFilePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compress(src string, buf io.Writer) error {
|
||||||
|
// tar > gzip > buf
|
||||||
|
zr := gzip.NewWriter(buf)
|
||||||
|
tw := tar.NewWriter(zr)
|
||||||
|
|
||||||
|
// walk through every file in the folder
|
||||||
|
err := filepath.Walk(src, func(file string, fi os.FileInfo, err error) error {
|
||||||
|
// if not a dir, write file content
|
||||||
|
if !fi.IsDir() {
|
||||||
|
// generate tar header
|
||||||
|
header, err := tar.FileInfoHeader(fi, file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
header.Name = filepath.ToSlash("/bundle/" + header.Name)
|
||||||
|
|
||||||
|
// write header
|
||||||
|
if err := tw.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore gosec G304 as this function is only used internally.
|
||||||
|
//nolint:gosec
|
||||||
|
data, err := os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(tw, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// produce tar
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// produce gzip
|
||||||
|
if err := zr.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
//
|
||||||
|
return nil
|
||||||
|
}
|
108
pkg/infra/supportbundles/supportbundlesimpl/store.go
Normal file
108
pkg/infra/supportbundles/supportbundlesimpl/store.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
package supportbundlesimpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/kvstore"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||||
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newStore(kv kvstore.KVStore) *store {
|
||||||
|
return &store{kv: kvstore.WithNamespace(kv, 0, "supportbundle")}
|
||||||
|
}
|
||||||
|
|
||||||
|
type store struct {
|
||||||
|
kv *kvstore.NamespacedKVStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) Create(ctx context.Context, usr *user.SignedInUser) (*supportbundles.Bundle, error) {
|
||||||
|
uid, err := uuid.NewRandom()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle := supportbundles.Bundle{
|
||||||
|
UID: uid.String(),
|
||||||
|
State: supportbundles.StatePending,
|
||||||
|
Creator: usr.Login,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.set(ctx, &bundle); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &bundle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) Update(ctx context.Context, uid string, state supportbundles.State, filePath string) error {
|
||||||
|
bundle, err := s.Get(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle.State = state
|
||||||
|
bundle.FilePath = filePath
|
||||||
|
|
||||||
|
return s.set(ctx, bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) set(ctx context.Context, bundle *supportbundles.Bundle) error {
|
||||||
|
data, err := json.Marshal(&bundle)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.kv.Set(ctx, bundle.UID, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) Get(ctx context.Context, uid string) (*supportbundles.Bundle, error) {
|
||||||
|
data, ok, err := s.kv.Get(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
// FIXME: handle not found
|
||||||
|
return nil, errors.New("not found")
|
||||||
|
}
|
||||||
|
var b supportbundles.Bundle
|
||||||
|
if err := json.NewDecoder(strings.NewReader(data)).Decode(&b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) Remove(ctx context.Context, uid string) error {
|
||||||
|
return s.kv.Del(ctx, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) List() ([]supportbundles.Bundle, error) {
|
||||||
|
data, err := s.kv.GetAll(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var res []supportbundles.Bundle
|
||||||
|
for _, items := range data {
|
||||||
|
for _, s := range items {
|
||||||
|
var b supportbundles.Bundle
|
||||||
|
if err := json.NewDecoder(strings.NewReader(s)).Decode(&b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res = append(res, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(res, func(i, j int) bool {
|
||||||
|
return res[i].CreatedAt < res[j].CreatedAt
|
||||||
|
})
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
package supportbundlesimpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||||
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func userCollector(users user.Service) supportbundles.Collector {
|
||||||
|
collectorFn := func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
||||||
|
query := &user.SearchUsersQuery{
|
||||||
|
SignedInUser: &user.SignedInUser{},
|
||||||
|
OrgID: 0,
|
||||||
|
Query: "",
|
||||||
|
Page: 0,
|
||||||
|
Limit: 0,
|
||||||
|
AuthModule: "",
|
||||||
|
Filters: []user.Filter{},
|
||||||
|
IsDisabled: new(bool),
|
||||||
|
}
|
||||||
|
res, err := users.Search(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userBytes, err := json.Marshal(res.Users)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &supportbundles.SupportItem{
|
||||||
|
Filename: "users.json",
|
||||||
|
FileBytes: userBytes,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return supportbundles.Collector{
|
||||||
|
UID: "users",
|
||||||
|
Description: "User information",
|
||||||
|
DisplayName: "A list of users of the Grafana instance",
|
||||||
|
IncludedByDefault: false,
|
||||||
|
Default: true,
|
||||||
|
Fn: collectorFn,
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/api"
|
"github.com/grafana/grafana/pkg/api"
|
||||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||||
"github.com/grafana/grafana/pkg/infra/remotecache"
|
"github.com/grafana/grafana/pkg/infra/remotecache"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/supportbundles/supportbundlesimpl"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
uss "github.com/grafana/grafana/pkg/infra/usagestats/service"
|
uss "github.com/grafana/grafana/pkg/infra/usagestats/service"
|
||||||
"github.com/grafana/grafana/pkg/infra/usagestats/statscollector"
|
"github.com/grafana/grafana/pkg/infra/usagestats/statscollector"
|
||||||
@ -46,8 +47,8 @@ func ProvideBackgroundServiceRegistry(
|
|||||||
secretsService *secretsManager.SecretsService, remoteCache *remotecache.RemoteCache,
|
secretsService *secretsManager.SecretsService, remoteCache *remotecache.RemoteCache,
|
||||||
thumbnailsService thumbs.Service, StorageService store.StorageService, searchService searchV2.SearchService, entityEventsService store.EntityEventsService,
|
thumbnailsService thumbs.Service, StorageService store.StorageService, searchService searchV2.SearchService, entityEventsService store.EntityEventsService,
|
||||||
saService *samanager.ServiceAccountsService, authInfoService *authinfoservice.Implementation,
|
saService *samanager.ServiceAccountsService, authInfoService *authinfoservice.Implementation,
|
||||||
grpcServerProvider grpcserver.Provider,
|
grpcServerProvider grpcserver.Provider, secretMigrationProvider secretsMigrations.SecretMigrationProvider, loginAttemptService *loginattemptimpl.Service,
|
||||||
secretMigrationProvider secretsMigrations.SecretMigrationProvider, loginAttemptService *loginattemptimpl.Service,
|
bundleService *supportbundlesimpl.Service,
|
||||||
// Need to make sure these are initialized, is there a better place to put them?
|
// Need to make sure these are initialized, is there a better place to put them?
|
||||||
_ dashboardsnapshots.Service, _ *alerting.AlertNotificationService,
|
_ dashboardsnapshots.Service, _ *alerting.AlertNotificationService,
|
||||||
_ serviceaccounts.Service, _ *guardian.Provider,
|
_ serviceaccounts.Service, _ *guardian.Provider,
|
||||||
@ -83,6 +84,7 @@ func ProvideBackgroundServiceRegistry(
|
|||||||
processManager,
|
processManager,
|
||||||
secretMigrationProvider,
|
secretMigrationProvider,
|
||||||
loginAttemptService,
|
loginAttemptService,
|
||||||
|
bundleService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,6 +20,8 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||||
"github.com/grafana/grafana/pkg/infra/remotecache"
|
"github.com/grafana/grafana/pkg/infra/remotecache"
|
||||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/supportbundles/supportbundlesimpl"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||||
uss "github.com/grafana/grafana/pkg/infra/usagestats/service"
|
uss "github.com/grafana/grafana/pkg/infra/usagestats/service"
|
||||||
@ -354,6 +356,8 @@ var wireBasicSet = wire.NewSet(
|
|||||||
authnimpl.ProvideService,
|
authnimpl.ProvideService,
|
||||||
wire.Bind(new(authn.Service), new(*authnimpl.Service)),
|
wire.Bind(new(authn.Service), new(*authnimpl.Service)),
|
||||||
k8saccess.ProvideK8SAccess,
|
k8saccess.ProvideK8SAccess,
|
||||||
|
supportbundlesimpl.ProvideService,
|
||||||
|
wire.Bind(new(supportbundles.Service), new(*supportbundlesimpl.Service)),
|
||||||
)
|
)
|
||||||
|
|
||||||
var wireSet = wire.NewSet(
|
var wireSet = wire.NewSet(
|
||||||
|
@ -153,6 +153,11 @@ var (
|
|||||||
State: FeatureStateAlpha,
|
State: FeatureStateAlpha,
|
||||||
RequiresDevMode: true,
|
RequiresDevMode: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "supportBundles",
|
||||||
|
Description: "Support bundles for troubleshooting",
|
||||||
|
State: FeatureStateAlpha,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "dashboardsFromStorage",
|
Name: "dashboardsFromStorage",
|
||||||
Description: "Load dashboards from the generic storage interface",
|
Description: "Load dashboards from the generic storage interface",
|
||||||
|
@ -115,6 +115,10 @@ const (
|
|||||||
// Explore native k8s integrations
|
// Explore native k8s integrations
|
||||||
FlagK8s = "k8s"
|
FlagK8s = "k8s"
|
||||||
|
|
||||||
|
// FlagSupportBundles
|
||||||
|
// Support bundles for troubleshooting
|
||||||
|
FlagSupportBundles = "supportBundles"
|
||||||
|
|
||||||
// FlagDashboardsFromStorage
|
// FlagDashboardsFromStorage
|
||||||
// Load dashboards from the generic storage interface
|
// Load dashboards from the generic storage interface
|
||||||
FlagDashboardsFromStorage = "dashboardsFromStorage"
|
FlagDashboardsFromStorage = "dashboardsFromStorage"
|
||||||
|
@ -243,6 +243,15 @@ func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqC
|
|||||||
helpVersion = setting.ApplicationName
|
helpVersion = setting.ApplicationName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
supportBundleNode := &navtree.NavLink{
|
||||||
|
Text: "Support bundles",
|
||||||
|
Id: "support-bundles",
|
||||||
|
Url: "/admin/support-bundles",
|
||||||
|
Icon: "wrench",
|
||||||
|
Section: navtree.NavSectionConfig,
|
||||||
|
SortWeight: navtree.WeightHelp,
|
||||||
|
}
|
||||||
|
|
||||||
treeRoot.AddSection(&navtree.NavLink{
|
treeRoot.AddSection(&navtree.NavLink{
|
||||||
Text: "Help",
|
Text: "Help",
|
||||||
SubTitle: helpVersion,
|
SubTitle: helpVersion,
|
||||||
@ -251,7 +260,7 @@ func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqC
|
|||||||
Icon: "question-circle",
|
Icon: "question-circle",
|
||||||
SortWeight: navtree.WeightHelp,
|
SortWeight: navtree.WeightHelp,
|
||||||
Section: navtree.NavSectionConfig,
|
Section: navtree.NavSectionConfig,
|
||||||
Children: []*navtree.NavLink{},
|
Children: []*navtree.NavLink{supportBundleNode},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -95,6 +95,8 @@ export function getNavTitle(navId: string | undefined) {
|
|||||||
return t('nav.service-accounts.title', 'Service accounts');
|
return t('nav.service-accounts.title', 'Service accounts');
|
||||||
case 'admin':
|
case 'admin':
|
||||||
return t('nav.admin.title', 'Server admin');
|
return t('nav.admin.title', 'Server admin');
|
||||||
|
case 'support-bundles':
|
||||||
|
return t('nav.support-bundles.title', 'Support Bundles');
|
||||||
case 'global-users':
|
case 'global-users':
|
||||||
return config.featureToggles.topnav
|
return config.featureToggles.topnav
|
||||||
? t('nav.global-users.title', 'Users')
|
? t('nav.global-users.title', 'Users')
|
||||||
@ -191,6 +193,8 @@ export function getNavSubTitle(navId: string | undefined) {
|
|||||||
return t('nav.server-settings.subtitle', 'View the settings defined in your Grafana config');
|
return t('nav.server-settings.subtitle', 'View the settings defined in your Grafana config');
|
||||||
case 'storage':
|
case 'storage':
|
||||||
return t('nav.storage.subtitle', 'Manage file storage');
|
return t('nav.storage.subtitle', 'Manage file storage');
|
||||||
|
case 'support-bundles':
|
||||||
|
return t('nav.support-bundles.subtitle', 'Download support bundles');
|
||||||
case 'admin':
|
case 'admin':
|
||||||
return config.featureToggles.topnav
|
return config.featureToggles.topnav
|
||||||
? t(
|
? t(
|
||||||
|
@ -7,7 +7,7 @@ import { contextSrv } from 'app/core/services/context_srv';
|
|||||||
|
|
||||||
import { ShowModalReactEvent } from '../../../types/events';
|
import { ShowModalReactEvent } from '../../../types/events';
|
||||||
import appEvents from '../../app_events';
|
import appEvents from '../../app_events';
|
||||||
import { getFooterLinks } from '../Footer/Footer';
|
import { FooterLink, getFooterLinks } from '../Footer/Footer';
|
||||||
import { OrgSwitcher } from '../OrgSwitcher';
|
import { OrgSwitcher } from '../OrgSwitcher';
|
||||||
import { HelpModal } from '../help/HelpModal';
|
import { HelpModal } from '../help/HelpModal';
|
||||||
|
|
||||||
@ -53,6 +53,7 @@ export const enrichConfigItems = (items: NavModelItem[], location: Location<unkn
|
|||||||
if (link.id === 'help') {
|
if (link.id === 'help') {
|
||||||
link.children = [
|
link.children = [
|
||||||
...getFooterLinks(),
|
...getFooterLinks(),
|
||||||
|
...getSupportBundleFooterLinks(),
|
||||||
{
|
{
|
||||||
id: 'keyboard-shortcuts',
|
id: 'keyboard-shortcuts',
|
||||||
text: t('nav.help/keyboard-shortcuts', 'Keyboard shortcuts'),
|
text: t('nav.help/keyboard-shortcuts', 'Keyboard shortcuts'),
|
||||||
@ -77,6 +78,22 @@ export const enrichConfigItems = (items: NavModelItem[], location: Location<unkn
|
|||||||
return items;
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export let getSupportBundleFooterLinks = (cfg = config): FooterLink[] => {
|
||||||
|
if (!cfg.featureToggles.supportBundles) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
target: '_self',
|
||||||
|
id: 'support-bundle',
|
||||||
|
text: t('nav.help/support-bundle', 'Support Bundles'),
|
||||||
|
icon: 'question-circle',
|
||||||
|
url: '/admin/support-bundles',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
export const enrichWithInteractionTracking = (item: NavModelItem, expandedState: boolean) => {
|
export const enrichWithInteractionTracking = (item: NavModelItem, expandedState: boolean) => {
|
||||||
const onClick = item.onClick;
|
const onClick = item.onClick;
|
||||||
item.onClick = () => {
|
item.onClick = () => {
|
||||||
|
73
public/app/features/support-bundles/SupportBundles.tsx
Normal file
73
public/app/features/support-bundles/SupportBundles.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useAsyncFn } from 'react-use';
|
||||||
|
|
||||||
|
import { dateTimeFormat } from '@grafana/data';
|
||||||
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
|
import { LinkButton } from '@grafana/ui';
|
||||||
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
|
||||||
|
const subTitle = (
|
||||||
|
<span>
|
||||||
|
Support bundles allow you to easily collect and share Grafana logs, configuration, and data with the Grafana Labs
|
||||||
|
team.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
type SupportBundleState = 'complete' | 'error' | 'timeout' | 'pending';
|
||||||
|
|
||||||
|
interface SupportBundle {
|
||||||
|
uid: string;
|
||||||
|
state: SupportBundleState;
|
||||||
|
creator: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBundles = () => {
|
||||||
|
return getBackendSrv().get<SupportBundle[]>('/api/support-bundles');
|
||||||
|
};
|
||||||
|
|
||||||
|
function SupportBundles() {
|
||||||
|
const [bundlesState, fetchBundles] = useAsyncFn(getBundles, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBundles();
|
||||||
|
}, [fetchBundles]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page navId="support-bundles" subTitle={subTitle}>
|
||||||
|
<Page.Contents isLoading={bundlesState.loading}>
|
||||||
|
<LinkButton href="admin/support-bundles/create" variant="primary">
|
||||||
|
Create New Support Bundle
|
||||||
|
</LinkButton>
|
||||||
|
|
||||||
|
<table className="filter-table form-inline">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Requested by</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th style={{ width: '1%' }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bundlesState?.value?.map((b) => (
|
||||||
|
<tr key={b.uid}>
|
||||||
|
<th>{dateTimeFormat(b.createdAt * 1000)}</th>
|
||||||
|
<th>{b.creator}</th>
|
||||||
|
<th>{dateTimeFormat(b.expiresAt * 1000)}</th>
|
||||||
|
<th>
|
||||||
|
<LinkButton disabled={b.state !== 'complete'} target={'_self'} href={'/api/support-bundles/' + b.uid}>
|
||||||
|
Download
|
||||||
|
</LinkButton>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Page.Contents>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SupportBundles;
|
96
public/app/features/support-bundles/SupportBundlesCreate.tsx
Normal file
96
public/app/features/support-bundles/SupportBundlesCreate.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useAsyncFn } from 'react-use';
|
||||||
|
|
||||||
|
import { getBackendSrv, locationService } from '@grafana/runtime';
|
||||||
|
import { Form, Button, Field, Checkbox } from '@grafana/ui';
|
||||||
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
|
||||||
|
// move to types
|
||||||
|
export interface SupportBundleCreateRequest {
|
||||||
|
collectors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportBundleCollector {
|
||||||
|
uid: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
includedByDefault: boolean;
|
||||||
|
default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Props {}
|
||||||
|
|
||||||
|
const createSupportBundle = async (data: SupportBundleCreateRequest) => {
|
||||||
|
const result = await getBackendSrv().post('/api/support-bundles', data);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SupportBundlesCreate = ({}: Props): JSX.Element => {
|
||||||
|
const onSubmit = useCallback(async (data) => {
|
||||||
|
try {
|
||||||
|
const selectedLabelsArray = Object.keys(data).filter((key) => data[key]);
|
||||||
|
const response = await createSupportBundle({ collectors: selectedLabelsArray });
|
||||||
|
console.info(response);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
locationService.push('/admin/support-bundles');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [components, setComponents] = useState<SupportBundleCollector[]>([]);
|
||||||
|
// populate components from the backend
|
||||||
|
const populateComponents = async () => {
|
||||||
|
return await getBackendSrv().get('/api/support-bundles/collectors');
|
||||||
|
};
|
||||||
|
|
||||||
|
const [state, fetchComponents] = useAsyncFn(populateComponents);
|
||||||
|
useEffect(() => {
|
||||||
|
fetchComponents().then((res) => {
|
||||||
|
setComponents(res);
|
||||||
|
});
|
||||||
|
}, [fetchComponents]);
|
||||||
|
|
||||||
|
// turn components into a uuid -> enabled map
|
||||||
|
const values: Record<string, boolean> = components.reduce((acc, curr) => {
|
||||||
|
return { ...acc, [curr.uid]: curr.default };
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page navId="support-bundles" pageNav={{ text: 'Create support bundle' }}>
|
||||||
|
<Page.Contents>
|
||||||
|
<Page.OldNavOnly>
|
||||||
|
<h3 className="page-sub-heading">Create support bundle</h3>
|
||||||
|
</Page.OldNavOnly>
|
||||||
|
{state.error && <p>{state.error}</p>}
|
||||||
|
{!!components.length && (
|
||||||
|
<Form defaultValues={values} onSubmit={onSubmit} validateOn="onSubmit">
|
||||||
|
{({ register, errors }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{components.map((component) => {
|
||||||
|
return (
|
||||||
|
<Field key={component.uid}>
|
||||||
|
<Checkbox
|
||||||
|
{...register(component.uid)}
|
||||||
|
label={component.displayName}
|
||||||
|
id={component.uid}
|
||||||
|
description={component.description}
|
||||||
|
defaultChecked={component.default}
|
||||||
|
disabled={component.includedByDefault}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button type="submit">Create</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Page.Contents>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SupportBundlesCreate;
|
@ -514,6 +514,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
...getBrowseStorageRoutes(),
|
...getBrowseStorageRoutes(),
|
||||||
...getDynamicDashboardRoutes(),
|
...getDynamicDashboardRoutes(),
|
||||||
...getPluginCatalogRoutes(),
|
...getPluginCatalogRoutes(),
|
||||||
|
...getSupportBundleRoutes(),
|
||||||
...getLiveRoutes(),
|
...getLiveRoutes(),
|
||||||
...getAlertingRoutes(),
|
...getAlertingRoutes(),
|
||||||
...getProfileRoutes(),
|
...getProfileRoutes(),
|
||||||
@ -552,6 +553,28 @@ export function getBrowseStorageRoutes(cfg = config): RouteDescriptor[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSupportBundleRoutes(cfg = config): RouteDescriptor[] {
|
||||||
|
if (!cfg.featureToggles.supportBundles) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
path: '/admin/support-bundles',
|
||||||
|
component: SafeDynamicImport(
|
||||||
|
() => import(/* webpackChunkName: "SupportBundles" */ 'app/features/support-bundles/SupportBundles')
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/support-bundles/create',
|
||||||
|
component: SafeDynamicImport(
|
||||||
|
() =>
|
||||||
|
import(/* webpackChunkName: "ServiceAccountCreatePage" */ 'app/features/support-bundles/SupportBundlesCreate')
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function getDynamicDashboardRoutes(cfg = config): RouteDescriptor[] {
|
export function getDynamicDashboardRoutes(cfg = config): RouteDescriptor[] {
|
||||||
if (!cfg.featureToggles.scenes) {
|
if (!cfg.featureToggles.scenes) {
|
||||||
return [];
|
return [];
|
||||||
|
@ -1194,6 +1194,12 @@ export const mockNavModel: NavIndex = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'support-bundles': {
|
||||||
|
id: 'support-bundles',
|
||||||
|
text: 'Support bundles',
|
||||||
|
icon: 'sliders-v-alt',
|
||||||
|
url: '/admin/support-bundles',
|
||||||
|
},
|
||||||
'server-settings': {
|
'server-settings': {
|
||||||
id: 'server-settings',
|
id: 'server-settings',
|
||||||
text: 'Settings',
|
text: 'Settings',
|
||||||
|
Reference in New Issue
Block a user